Phase 4: HTMX frontend with dark palette

- 4 pages: Pantry, Recipes, Shopping List, Cook Log
- HTMX-powered: add/delete pantry items, toggle shopping, generate smart list
- Custom 13-colour palette from Lospec (dark bg, yellow accent)
- Mobile-responsive
- Whitenoise for static files in production
- All routes under /app/
- API (/api/) stays internal, frontend (/app/) for browser use
This commit is contained in:
Caine
2026-04-02 23:08:43 +01:00
parent 75e3256eae
commit 2a45be70e2
16 changed files with 1078 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Food App (Meal Planning API)
After=network.target
[Service]
Type=notify
User=openclaw
Group=openclaw
WorkingDirectory=/var/lib/food
Environment="PATH=/var/lib/food/venv/bin:/usr/bin"
Environment="DJANGO_SETTINGS_MODULE=food_project.settings"
ExecStart=/var/lib/food/venv/bin/gunicorn food_project.wsgi:application \
--bind 127.0.0.1:8042 \
--workers 2 \
--timeout 30 \
--access-logfile - \
--error-logfile -
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+7
View File
@@ -55,6 +55,7 @@ REST_FRAMEWORK = {
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
@@ -129,6 +130,12 @@ USE_TZ = True
# https://docs.djangoproject.com/en/5.2/howto/static-files/ # https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/' STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
+1
View File
@@ -21,4 +21,5 @@ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('api/', include('kitchen.urls')), path('api/', include('kitchen.urls')),
path('api-auth/', include('rest_framework.urls')), # browsable API login path('api-auth/', include('rest_framework.urls')), # browsable API login
path('app/', include('kitchen.urls_htmx')), # HTMX frontend
] ]
File diff suppressed because one or more lines are too long
+409
View File
@@ -0,0 +1,409 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Kitchen{% endblock %}</title>
<script src="{% static 'kitchen/htmx.min.js' %}"></script>
<style>
:root {
--bg-dark: #15131c;
--navy: #0e0e5b;
--teal-dark: #325664;
--red-dark: #aa1010;
--orange-burnt: #bd6611;
--sage: #658d89;
--red-bright: #f01111;
--yellow: #f9df11;
--orange-warm: #fa9f45;
--teal-light: #87d1d1;
--grey-light: #babcc4;
--peach: #fdceb0;
--cream: #f7fdc7;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg-dark);
color: var(--grey-light);
min-height: 100vh;
line-height: 1.5;
}
/* --- NAV --- */
nav {
background: var(--navy);
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
gap: 2rem;
border-bottom: 2px solid var(--teal-dark);
position: sticky;
top: 0;
z-index: 100;
}
nav .logo {
font-size: 1.25rem;
font-weight: 700;
color: var(--yellow);
text-decoration: none;
letter-spacing: 0.5px;
}
nav .links {
display: flex;
gap: 0.25rem;
}
nav a {
color: var(--grey-light);
text-decoration: none;
padding: 0.4rem 0.75rem;
border-radius: 4px;
font-size: 0.9rem;
transition: all 0.15s;
}
nav a:hover, nav a.active {
background: var(--teal-dark);
color: var(--cream);
}
/* --- LAYOUT --- */
.container {
max-width: 960px;
margin: 0 auto;
padding: 1.5rem;
}
h1 {
color: var(--yellow);
font-size: 1.5rem;
margin-bottom: 1rem;
font-weight: 600;
}
h2 {
color: var(--teal-light);
font-size: 1.15rem;
margin: 1.5rem 0 0.75rem;
font-weight: 600;
}
/* --- CARDS --- */
.card {
background: rgba(50, 86, 100, 0.15);
border: 1px solid var(--teal-dark);
border-radius: 6px;
padding: 1rem;
margin-bottom: 0.75rem;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.card-title {
color: var(--cream);
font-weight: 600;
font-size: 1rem;
}
/* --- TABLES --- */
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
th {
text-align: left;
color: var(--teal-light);
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--teal-dark);
}
td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid rgba(50, 86, 100, 0.3);
font-size: 0.9rem;
}
tr:hover {
background: rgba(50, 86, 100, 0.15);
}
/* --- BADGES --- */
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-fridge { background: var(--teal-dark); color: var(--teal-light); }
.badge-freezer { background: var(--navy); color: var(--teal-light); }
.badge-cupboard { background: rgba(189, 102, 17, 0.25); color: var(--orange-warm); }
.badge-ok { background: rgba(101, 141, 137, 0.25); color: var(--sage); }
.badge-expiring { background: rgba(250, 159, 69, 0.25); color: var(--orange-warm); }
.badge-expired { background: rgba(240, 17, 17, 0.25); color: var(--red-bright); }
.badge-ready { background: rgba(101, 141, 137, 0.3); color: var(--sage); }
.badge-partial { background: rgba(249, 223, 17, 0.2); color: var(--yellow); }
.badge-missing { background: rgba(170, 16, 16, 0.25); color: var(--red-bright); }
/* --- RATING --- */
.stars { color: var(--yellow); letter-spacing: 1px; }
.stars-empty { color: var(--teal-dark); }
/* --- BUTTONS --- */
.btn {
display: inline-block;
padding: 0.4rem 0.9rem;
border: none;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
}
.btn-primary {
background: var(--yellow);
color: var(--bg-dark);
}
.btn-primary:hover { background: var(--cream); }
.btn-secondary {
background: var(--teal-dark);
color: var(--grey-light);
}
.btn-secondary:hover { background: var(--sage); color: var(--cream); }
.btn-danger {
background: transparent;
color: var(--red-dark);
border: 1px solid var(--red-dark);
}
.btn-danger:hover { background: var(--red-dark); color: var(--cream); }
.btn-sm {
padding: 0.2rem 0.5rem;
font-size: 0.75rem;
}
/* --- FORMS --- */
input, select, textarea {
background: rgba(14, 14, 91, 0.3);
border: 1px solid var(--teal-dark);
color: var(--cream);
padding: 0.4rem 0.6rem;
border-radius: 4px;
font-size: 0.9rem;
font-family: inherit;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--yellow);
box-shadow: 0 0 0 2px rgba(249, 223, 17, 0.15);
}
input::placeholder { color: var(--sage); }
label {
display: block;
color: var(--teal-light);
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.25rem;
}
.form-group { margin-bottom: 0.75rem; }
.form-row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.form-row .form-group { flex: 1; min-width: 120px; }
/* --- SLOT PICKER --- */
.slot {
margin-bottom: 1rem;
}
.slot-name {
color: var(--orange-warm);
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.4rem;
}
.slot-options {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.slot-option {
padding: 0.3rem 0.7rem;
border: 1px solid var(--teal-dark);
border-radius: 4px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
background: transparent;
color: var(--grey-light);
}
.slot-option:hover {
border-color: var(--yellow);
color: var(--cream);
}
.slot-option.selected {
background: rgba(249, 223, 17, 0.15);
border-color: var(--yellow);
color: var(--yellow);
}
.slot-option.unavailable {
opacity: 0.4;
border-style: dashed;
}
/* --- SHOPPING LIST --- */
.shop-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(50, 86, 100, 0.2);
}
.shop-item input[type="checkbox"] {
accent-color: var(--yellow);
width: 1.1rem;
height: 1.1rem;
}
.shop-item.checked .shop-name {
text-decoration: line-through;
color: var(--sage);
opacity: 0.6;
}
.shop-section {
color: var(--orange-warm);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 0.75rem 0 0.25rem;
}
/* --- COOK LOG --- */
.log-entry {
display: flex;
gap: 1rem;
align-items: flex-start;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(50, 86, 100, 0.2);
}
.log-date {
color: var(--sage);
font-size: 0.8rem;
min-width: 5rem;
}
.log-notes {
color: var(--grey-light);
font-size: 0.85rem;
font-style: italic;
margin-top: 0.25rem;
}
/* --- EMPTY STATE --- */
.empty {
text-align: center;
padding: 3rem;
color: var(--sage);
}
.empty-icon { font-size: 2rem; margin-bottom: 0.5rem; }
/* --- HTMX LOADING --- */
.htmx-indicator {
display: none;
color: var(--yellow);
font-size: 0.8rem;
}
.htmx-request .htmx-indicator { display: inline; }
.htmx-request.htmx-indicator { display: inline; }
/* --- TOAST --- */
.toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
padding: 0.75rem 1.25rem;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
z-index: 1000;
animation: fadeIn 0.2s, fadeOut 0.3s 2.5s forwards;
}
.toast-success { background: var(--sage); color: var(--bg-dark); }
.toast-error { background: var(--red-dark); color: var(--cream); }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
/* --- RESPONSIVE --- */
@media (max-width: 640px) {
nav { padding: 0.5rem 1rem; gap: 1rem; flex-wrap: wrap; }
.container { padding: 1rem; }
.form-row { flex-direction: column; }
.form-row .form-group { min-width: 100%; }
td, th { padding: 0.35rem 0.5rem; font-size: 0.8rem; }
}
</style>
</head>
<body>
<nav>
<a href="{% url 'app-pantry' %}" class="logo">🍳 Kitchen</a>
<div class="links">
<a href="{% url 'app-pantry' %}" {% if active_tab == 'pantry' %}class="active"{% endif %}>Pantry</a>
<a href="{% url 'app-recipes' %}" {% if active_tab == 'recipes' %}class="active"{% endif %}>Recipes</a>
<a href="{% url 'app-shopping' %}" {% if active_tab == 'shopping' %}class="active"{% endif %}>Shopping</a>
<a href="{% url 'app-log' %}" {% if active_tab == 'log' %}class="active"{% endif %}>Cook Log</a>
</div>
</nav>
<div class="container">
{% block content %}{% endblock %}
</div>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
{% extends "kitchen/base.html" %}
{% block title %}Cook Log — Kitchen{% endblock %}
{% block content %}
<h1>Cook Log</h1>
<div id="log-entries">
{% include "kitchen/partials/cook_log.html" %}
</div>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "kitchen/base.html" %}
{% block title %}Pantry — Kitchen{% endblock %}
{% block content %}
<h1>Pantry</h1>
<!-- Add item form -->
<div class="card" style="margin-bottom: 1.5rem;">
<form hx-post="{% url 'app-pantry-add' %}" hx-target="#pantry-items" hx-swap="innerHTML" hx-on::after-request="if(event.detail.successful) this.reset()">
{% csrf_token %}
<div class="form-row">
<div class="form-group" style="flex: 2;">
<label for="ingredient_name">Ingredient</label>
<input type="text" id="ingredient_name" name="ingredient_name" placeholder="e.g. chicken thighs" required style="width: 100%;">
</div>
<div class="form-group">
<label for="quantity">Qty</label>
<input type="number" id="quantity" name="quantity" step="0.01" placeholder="2" required style="width: 100%;">
</div>
<div class="form-group">
<label for="unit">Unit</label>
<input type="text" id="unit" name="unit" placeholder="items" required style="width: 100%;">
</div>
<div class="form-group">
<label for="location">Location</label>
<select id="location" name="location" style="width: 100%;">
<option value="fridge">Fridge</option>
<option value="freezer">Freezer</option>
<option value="cupboard">Cupboard</option>
</select>
</div>
<div class="form-group" style="display: flex; align-items: flex-end;">
<button type="submit" class="btn btn-primary">Add</button>
</div>
</div>
</form>
</div>
<!-- Pantry items -->
<div id="pantry-items">
{% include "kitchen/partials/pantry_table.html" %}
</div>
{% endblock %}
@@ -0,0 +1,31 @@
{% for entry in entries %}
<div class="log-entry">
<div class="log-date">{{ entry.date|date:"j M" }}</div>
<div style="flex: 1;">
<div>
<span class="card-title">{{ entry.recipe_name }}</span>
{% if entry.rating %}
<span class="stars" style="margin-left: 0.5rem;">
{% for i in "12345" %}{% if forloop.counter <= entry.rating %}★{% else %}<span class="stars-empty"></span>{% endif %}{% endfor %}
</span>
{% endif %}
</div>
{% if entry.slot_choices %}
<div style="margin-top: 0.25rem;">
{% for slot, choice in entry.slot_choices.items %}
<span class="badge badge-cupboard">{{ choice }}</span>
{% endfor %}
</div>
{% endif %}
{% if entry.notes %}
<div class="log-notes">{{ entry.notes }}</div>
{% endif %}
<div style="color: var(--sage); font-size: 0.75rem; margin-top: 0.25rem;">{{ entry.servings }} serving{{ entry.servings|pluralize }}</div>
</div>
</div>
{% empty %}
<div class="empty">
<div class="empty-icon">📝</div>
<p>No cooks logged yet.</p>
</div>
{% endfor %}
@@ -0,0 +1,82 @@
{% if fridge_items or freezer_items or cupboard_items %}
{% if fridge_items %}
<h2>🧊 Fridge</h2>
<table>
<thead><tr><th>Item</th><th>Qty</th><th>Expiry</th><th></th></tr></thead>
<tbody>
{% for item in fridge_items %}
<tr id="pantry-row-{{ item.id }}">
<td>{{ item.ingredient.name }}</td>
<td>{{ item.quantity|floatformat:0 }} {{ item.unit }}</td>
<td>
{% if item.is_expired %}
<span class="badge badge-expired">Expired {{ item.expiry_date }}</span>
{% elif item.expiring_soon %}
<span class="badge badge-expiring">{{ item.expiry_date }}</span>
{% elif item.expiry_date %}
<span class="badge badge-ok">{{ item.expiry_date }}</span>
{% else %}
<span style="color: var(--sage);"></span>
{% endif %}
</td>
<td style="text-align: right;">
<button class="btn btn-danger btn-sm"
hx-delete="{% url 'app-pantry-delete' item.id %}"
hx-target="#pantry-items"
hx-confirm="Remove {{ item.ingredient.name }}?"></button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if freezer_items %}
<h2>❄️ Freezer</h2>
<table>
<thead><tr><th>Item</th><th>Qty</th><th></th></tr></thead>
<tbody>
{% for item in freezer_items %}
<tr id="pantry-row-{{ item.id }}">
<td>{{ item.ingredient.name }}</td>
<td>{{ item.quantity|floatformat:0 }} {{ item.unit }}</td>
<td style="text-align: right;">
<button class="btn btn-danger btn-sm"
hx-delete="{% url 'app-pantry-delete' item.id %}"
hx-target="#pantry-items"
hx-confirm="Remove {{ item.ingredient.name }}?"></button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if cupboard_items %}
<h2>🗄️ Cupboard</h2>
<table>
<thead><tr><th>Item</th><th>Qty</th><th></th></tr></thead>
<tbody>
{% for item in cupboard_items %}
<tr id="pantry-row-{{ item.id }}">
<td>{{ item.ingredient.name }}{% if item.is_staple %} <span style="color: var(--yellow); font-size: 0.7rem;">STAPLE</span>{% endif %}</td>
<td>{{ item.quantity|floatformat:0 }} {{ item.unit }}</td>
<td style="text-align: right;">
<button class="btn btn-danger btn-sm"
hx-delete="{% url 'app-pantry-delete' item.id %}"
hx-target="#pantry-items"
hx-confirm="Remove {{ item.ingredient.name }}?"></button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% else %}
<div class="empty">
<div class="empty-icon">🥡</div>
<p>Pantry's empty. Add some items above.</p>
</div>
{% endif %}
@@ -0,0 +1,63 @@
{% for recipe in recipes %}
<div class="card">
<div class="card-header">
<span class="card-title">
{% if recipe.status == 'ready' %}
<span class="badge badge-ready">✅ Ready</span>
{% elif recipe.status == 'partial' %}
<span class="badge badge-partial">⚠️ Partial</span>
{% else %}
<span class="badge badge-missing">❌ Missing</span>
{% endif %}
{{ recipe.name }}
</span>
{% if recipe.type == 'meta_recipe' %}
<span style="color: var(--sage); font-size: 0.8rem;">{{ recipe.gear }}</span>
{% endif %}
</div>
{% if recipe.type == 'meta_recipe' %}
{% for slot in recipe.slots %}
<div class="slot">
<div class="slot-name">
{{ slot.name }}
{% if not slot.required %}<span style="color: var(--sage); font-size: 0.7rem;">(optional)</span>{% endif %}
</div>
<div class="slot-options">
{% for opt in slot.available_options %}
<span class="slot-option" title="Have: {{ opt.have }}{% if opt.notes %} — {{ opt.notes }}{% endif %}">
{{ opt.ingredient }}
</span>
{% endfor %}
{% for opt in slot.missing_options %}
<span class="slot-option unavailable" title="Need: {{ opt.needed }}{% if opt.notes %} — {{ opt.notes }}{% endif %}">
{{ opt.ingredient }}
</span>
{% endfor %}
</div>
</div>
{% endfor %}
{% if recipe.base_missing %}
<div style="margin-top: 0.5rem;">
<span style="color: var(--red-bright); font-size: 0.8rem;">
Missing base: {% for b in recipe.base_missing %}{{ b.ingredient }} ({{ b.needed }}){% if not forloop.last %}, {% endif %}{% endfor %}
</span>
</div>
{% endif %}
{% endif %}
{% if recipe.warnings %}
<div style="margin-top: 0.5rem;">
{% for w in recipe.warnings %}
<span style="color: var(--orange-warm); font-size: 0.8rem;">⚠ {{ w }}</span><br>
{% endfor %}
</div>
{% endif %}
</div>
{% empty %}
<div class="empty">
<div class="empty-icon">📖</div>
<p>No recipes yet.</p>
</div>
{% endfor %}
@@ -0,0 +1,25 @@
{% regroup items by section as sections %}
{% for section in sections %}
<div class="shop-section">{{ section.grouper|default:"Other" }}</div>
{% for item in section.list %}
<div class="shop-item {% if item.checked %}checked{% endif %}">
<input type="checkbox"
{% if item.checked %}checked{% endif %}
hx-post="{% url 'app-shopping-toggle' item.id %}"
hx-target="#shopping-items"
hx-swap="innerHTML">
<span class="shop-name">
{{ item.name }}
{% if item.quantity %}<span style="color: var(--sage);">× {{ item.quantity|floatformat:0 }} {{ item.unit }}</span>{% endif %}
</span>
{% if item.reason %}
<span style="color: var(--sage); font-size: 0.75rem; margin-left: auto;">{{ item.reason }}</span>
{% endif %}
</div>
{% endfor %}
{% empty %}
<div class="empty">
<div class="empty-icon">🛒</div>
<p>Shopping list is empty. Hit "Generate Smart List" to get suggestions.</p>
</div>
{% endfor %}
+10
View File
@@ -0,0 +1,10 @@
{% extends "kitchen/base.html" %}
{% block title %}Recipes — Kitchen{% endblock %}
{% block content %}
<h1>What Can I Cook?</h1>
<div id="recipe-list">
{% include "kitchen/partials/recipe_list.html" %}
</div>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "kitchen/base.html" %}
{% block title %}Shopping List — Kitchen{% endblock %}
{% block content %}
<h1>Shopping List</h1>
<div style="display: flex; gap: 0.75rem; margin-bottom: 1.5rem;">
<button class="btn btn-primary"
hx-post="{% url 'app-shopping-generate' %}"
hx-target="#shopping-items"
hx-swap="innerHTML">
🔄 Generate Smart List
</button>
<button class="btn btn-secondary"
hx-post="{% url 'app-shopping-clear' %}"
hx-target="#shopping-items"
hx-swap="innerHTML"
hx-confirm="Clear checked items?">
Clear Checked
</button>
</div>
<div id="shopping-items">
{% include "kitchen/partials/shopping_list.html" %}
</div>
{% endblock %}
+17
View File
@@ -0,0 +1,17 @@
from django.urls import path
from . import views_htmx
urlpatterns = [
# Full pages
path("", views_htmx.pantry_page, name="app-pantry"),
path("recipes/", views_htmx.recipes_page, name="app-recipes"),
path("shopping/", views_htmx.shopping_page, name="app-shopping"),
path("log/", views_htmx.log_page, name="app-log"),
# HTMX partials
path("pantry/add/", views_htmx.pantry_add, name="app-pantry-add"),
path("pantry/<int:item_id>/delete/", views_htmx.pantry_delete, name="app-pantry-delete"),
path("shopping/generate/", views_htmx.shopping_generate, name="app-shopping-generate"),
path("shopping/<int:item_id>/toggle/", views_htmx.shopping_toggle, name="app-shopping-toggle"),
path("shopping/clear/", views_htmx.shopping_clear, name="app-shopping-clear"),
]
+328
View File
@@ -0,0 +1,328 @@
"""
HTMX views — return HTML fragments for the frontend.
Separate from the DRF JSON API views.
"""
from datetime import date, timedelta
from decimal import Decimal
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.views.decorators.http import require_POST, require_http_methods
from .models import (
Ingredient, PantryItem, MetaRecipe, Slot, SlotOption,
Recipe, CookLog, ShoppingListItem,
)
# --- Helpers ---
def _pantry_context():
"""Build pantry items grouped by location with expiry annotations."""
items = PantryItem.objects.select_related("ingredient").filter(quantity__gt=0)
today = date.today()
for item in items:
item.is_expired = item.expiry_date and item.expiry_date < today
item.expiring_soon = (
item.expiry_date
and not item.is_expired
and (item.expiry_date - today).days <= 2
)
return {
"fridge_items": [i for i in items if i.location == "fridge"],
"freezer_items": [i for i in items if i.location == "freezer"],
"cupboard_items": [i for i in items if i.location == "cupboard"],
}
def _get_pantry_total(ingredient_id):
total = Decimal("0")
for item in PantryItem.objects.filter(ingredient_id=ingredient_id, quantity__gt=0):
total += item.quantity
return total
def _find_ingredient(name):
"""Find ingredient by name or alias (case-insensitive)."""
try:
return Ingredient.objects.get(name__iexact=name)
except Ingredient.DoesNotExist:
pass
for ingredient in Ingredient.objects.all():
if ingredient.aliases and any(a.lower() == name.lower() for a in ingredient.aliases):
return ingredient
return None
# --- Page Views ---
def pantry_page(request):
ctx = _pantry_context()
ctx["active_tab"] = "pantry"
return render(request, "kitchen/pantry.html", ctx)
def recipes_page(request):
servings = 2
today = date.today()
# Build pantry lookup
pantry = {}
for item in PantryItem.objects.select_related("ingredient").filter(quantity__gt=0):
if item.ingredient_id not in pantry:
pantry[item.ingredient_id] = []
pantry[item.ingredient_id].append({
"quantity": item.quantity,
"unit": item.unit,
"location": item.location,
"expiry_date": item.expiry_date,
})
def get_total(ing_id):
return sum(p["quantity"] for p in pantry.get(ing_id, []))
def get_warnings(ing_id):
warnings = []
for p in pantry.get(ing_id, []):
if p["expiry_date"]:
if p["expiry_date"] < today:
warnings.append(f"EXPIRED in {p['location']}")
elif (p["expiry_date"] - today).days <= 2:
warnings.append(f"expiring soon in {p['location']}")
return warnings
recipes = []
for meta in MetaRecipe.objects.prefetch_related(
"slots__options__ingredient", "base_ingredients__ingredient"
).all():
result = {
"type": "meta_recipe",
"name": meta.name,
"gear": meta.gear_needed,
"slots": [],
"base_missing": [],
"warnings": [],
"status": "ready",
}
for base in meta.base_ingredients.all():
needed = base.quantity_per_serving * servings
available = get_total(base.ingredient_id)
ws = get_warnings(base.ingredient_id)
is_staple = PantryItem.objects.filter(ingredient=base.ingredient, is_staple=True).exists()
if available < needed and not is_staple:
result["base_missing"].append({
"ingredient": base.ingredient.name,
"needed": f"{needed} {base.unit}",
})
result["warnings"].extend([f"{base.ingredient.name}: {w}" for w in ws])
for slot in meta.slots.all():
slot_data = {
"name": slot.name,
"required": slot.required,
"available_options": [],
"missing_options": [],
}
for opt in slot.options.all():
needed = opt.quantity_per_serving * servings
available = get_total(opt.ingredient_id)
ws = get_warnings(opt.ingredient_id)
info = {
"ingredient": opt.ingredient.name,
"needed": f"{needed} {opt.unit}",
"have": f"{available} {opt.unit}",
"notes": opt.notes,
"warnings": ws,
}
if available >= needed:
slot_data["available_options"].append(info)
else:
slot_data["missing_options"].append(info)
if slot.required and not slot_data["available_options"]:
result["status"] = "missing"
result["slots"].append(slot_data)
if result["status"] == "ready" and result["base_missing"]:
result["status"] = "partial"
recipes.append(result)
# Sort: ready > partial > missing
order = {"ready": 0, "partial": 1, "missing": 2}
recipes.sort(key=lambda r: order.get(r["status"], 3))
return render(request, "kitchen/recipes.html", {
"recipes": recipes,
"active_tab": "recipes",
})
def shopping_page(request):
items = ShoppingListItem.objects.select_related("ingredient").all()
# Add section info
for item in items:
item.section = _get_section_for_item(item)
return render(request, "kitchen/shopping.html", {
"items": items,
"active_tab": "shopping",
})
def log_page(request):
entries = CookLog.objects.select_related("meta_recipe", "recipe").order_by("-date")[:50]
log_data = []
for entry in entries:
log_data.append({
"date": entry.date,
"recipe_name": entry.meta_recipe.name if entry.meta_recipe else (entry.recipe.name if entry.recipe else "Unknown"),
"rating": entry.rating,
"slot_choices": entry.slot_choices,
"notes": entry.notes,
"servings": entry.servings,
})
return render(request, "kitchen/log.html", {
"entries": log_data,
"active_tab": "log",
})
# --- HTMX Actions ---
@require_POST
def pantry_add(request):
name = request.POST.get("ingredient_name", "").strip()
quantity = Decimal(request.POST.get("quantity", "0"))
unit = request.POST.get("unit", "items")
location = request.POST.get("location", "fridge")
if not name:
return HttpResponse("", status=400)
ingredient = _find_ingredient(name)
if not ingredient:
ingredient = Ingredient.objects.create(
name=name.lower(),
default_unit=unit,
)
expiry_date = None
if location == "fridge" and ingredient.shelf_life_days:
expiry_date = date.today() + timedelta(days=ingredient.shelf_life_days)
# Check for existing in same location
existing = PantryItem.objects.filter(
ingredient=ingredient, location=location, quantity__gt=0
).first()
if existing:
existing.quantity += quantity
if expiry_date:
existing.expiry_date = expiry_date
existing.save(update_fields=["quantity", "expiry_date"])
else:
PantryItem.objects.create(
ingredient=ingredient,
quantity=quantity,
unit=unit,
location=location,
expiry_date=expiry_date,
)
ctx = _pantry_context()
return render(request, "kitchen/partials/pantry_table.html", ctx)
@require_http_methods(["DELETE"])
def pantry_delete(request, item_id):
item = get_object_or_404(PantryItem, id=item_id)
item.delete()
ctx = _pantry_context()
return render(request, "kitchen/partials/pantry_table.html", ctx)
@require_POST
def shopping_generate(request):
"""Generate smart shopping list and return updated HTML."""
from .views import _get_section, _find_ingredient as api_find, _is_known_staple
suggestions = []
# Staples at zero
for item in PantryItem.objects.filter(is_staple=True, quantity=0).select_related("ingredient"):
suggestions.append({
"ingredient": item.ingredient.name,
"reason": "restock staple",
"section": _get_section(item.ingredient),
})
# Expiring items
cutoff = date.today() + timedelta(days=2)
for item in PantryItem.objects.filter(
expiry_date__isnull=False, expiry_date__lte=cutoff, quantity__gt=0
).select_related("ingredient"):
suggestions.append({
"ingredient": item.ingredient.name,
"reason": f"expiring {item.expiry_date}",
"section": _get_section(item.ingredient),
})
# Dedupe and save
seen = set()
for s in suggestions:
if s["ingredient"] not in seen:
seen.add(s["ingredient"])
ingredient = _find_ingredient(s["ingredient"])
ShoppingListItem.objects.get_or_create(
name=s["ingredient"],
checked=False,
defaults={
"ingredient": ingredient,
"reason": s["reason"],
},
)
items = ShoppingListItem.objects.select_related("ingredient").all()
for item in items:
item.section = _get_section_for_item(item)
return render(request, "kitchen/partials/shopping_list.html", {"items": items})
@require_POST
def shopping_toggle(request, item_id):
item = get_object_or_404(ShoppingListItem, id=item_id)
item.checked = not item.checked
item.save(update_fields=["checked"])
items = ShoppingListItem.objects.select_related("ingredient").all()
for i in items:
i.section = _get_section_for_item(i)
return render(request, "kitchen/partials/shopping_list.html", {"items": items})
@require_POST
def shopping_clear(request):
ShoppingListItem.objects.filter(checked=True).delete()
items = ShoppingListItem.objects.select_related("ingredient").all()
for item in items:
item.section = _get_section_for_item(item)
return render(request, "kitchen/partials/shopping_list.html", {"items": items})
def _get_section_for_item(item):
if item.ingredient:
tag_names = set(item.ingredient.tags.values_list("name", flat=True))
if "protein" in tag_names:
return "Protein"
if "veg" in tag_names:
return "Veg"
if "carb" in tag_names:
return "Carbs"
if "dairy" in tag_names:
return "Dairy"
return "Other"
+3
View File
@@ -5,6 +5,7 @@ charset-normalizer==3.4.7
Django==5.2.12 Django==5.2.12
djangorestframework==3.17.1 djangorestframework==3.17.1
extruct==0.18.0 extruct==0.18.0
gunicorn==25.3.0
html-text==0.7.1 html-text==0.7.1
html5lib==1.1 html5lib==1.1
idna==3.11 idna==3.11
@@ -13,6 +14,7 @@ jstyleson==0.0.2
lxml==6.0.2 lxml==6.0.2
lxml_html_clean==0.4.4 lxml_html_clean==0.4.4
mf2py==2.0.1 mf2py==2.0.1
packaging==26.0
pyparsing==3.3.2 pyparsing==3.3.2
pyrdfa3==3.6.5 pyrdfa3==3.6.5
rdflib==7.6.0 rdflib==7.6.0
@@ -25,3 +27,4 @@ typing_extensions==4.15.0
urllib3==2.6.3 urllib3==2.6.3
w3lib==2.4.1 w3lib==2.4.1
webencodings==0.5.1 webencodings==0.5.1
whitenoise==6.12.0