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:
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user