Compare commits
2
Commits
pantry-redesign
...
auth
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba1f792826 | ||
|
|
6bad2a2ad1 |
@@ -9,6 +9,9 @@ Group=openclaw
|
|||||||
WorkingDirectory=/var/lib/food
|
WorkingDirectory=/var/lib/food
|
||||||
Environment="PATH=/var/lib/food/.venv/bin:/usr/bin"
|
Environment="PATH=/var/lib/food/.venv/bin:/usr/bin"
|
||||||
Environment="DJANGO_SETTINGS_MODULE=food_project.settings"
|
Environment="DJANGO_SETTINGS_MODULE=food_project.settings"
|
||||||
|
# Secrets/config (DJANGO_SECRET_KEY, optional DJANGO_DEBUG) live here, not in git.
|
||||||
|
# The leading '-' makes it optional so the service still starts if absent.
|
||||||
|
EnvironmentFile=-/var/lib/food/.env
|
||||||
ExecStart=/var/lib/food/.venv/bin/gunicorn food_project.wsgi:application \
|
ExecStart=/var/lib/food/.venv/bin/gunicorn food_project.wsgi:application \
|
||||||
--bind 127.0.0.1:8042 \
|
--bind 127.0.0.1:8042 \
|
||||||
--workers 2 \
|
--workers 2 \
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ For the full list of settings and their values, see
|
|||||||
https://docs.djangoproject.com/en/5.2/ref/settings/
|
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
@@ -20,10 +21,16 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = 'django-insecure-4v$$nwxx6)+2yz%$8c@+kocm#op1cjm*688np#)z$b_6crvub*'
|
# Set DJANGO_SECRET_KEY in the environment for production; the literal below is
|
||||||
|
# only a development fallback.
|
||||||
|
SECRET_KEY = os.environ.get(
|
||||||
|
"DJANGO_SECRET_KEY",
|
||||||
|
"django-insecure-4v$$nwxx6)+2yz%$8c@+kocm#op1cjm*688np#)z$b_6crvub*",
|
||||||
|
)
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = True
|
# Off by default (prod-safe); set DJANGO_DEBUG=1 for local development.
|
||||||
|
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"
|
||||||
|
|
||||||
ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'food.jihakuz.xyz', 'food.tomflux.xyz']
|
ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'food.jihakuz.xyz', 'food.tomflux.xyz']
|
||||||
|
|
||||||
@@ -60,6 +67,7 @@ MIDDLEWARE = [
|
|||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'kitchen.middleware.AppLoginRequiredMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
]
|
]
|
||||||
@@ -141,3 +149,24 @@ STORAGES = {
|
|||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
|
||||||
|
# --- Auth / sessions ---
|
||||||
|
# Goal: log in once, then effectively never again on that device.
|
||||||
|
# A long cookie age + sliding expiry (re-saved each request) means an active
|
||||||
|
# user stays logged in indefinitely.
|
||||||
|
SESSION_COOKIE_AGE = 60 * 60 * 24 * 365 # ~1 year, in seconds
|
||||||
|
SESSION_EXPIRE_AT_BROWSER_CLOSE = False
|
||||||
|
SESSION_SAVE_EVERY_REQUEST = True
|
||||||
|
|
||||||
|
LOGIN_URL = 'login'
|
||||||
|
LOGIN_REDIRECT_URL = 'app-pantry'
|
||||||
|
LOGOUT_REDIRECT_URL = 'login'
|
||||||
|
|
||||||
|
# Behind the nginx HTTPS reverse proxy: trust the forwarded scheme so Django
|
||||||
|
# knows requests are HTTPS, and trust the site's origins for CSRF (needed for
|
||||||
|
# the login POST and HTMX POSTs now that they're no longer csrf-exempt).
|
||||||
|
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||||
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
|
'https://food.tomflux.xyz',
|
||||||
|
]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from django.urls import path, include
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
|
path('accounts/', include('django.contrib.auth.urls')), # login/logout for the web UI
|
||||||
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
|
path('app/', include('kitchen.urls_htmx')), # HTMX frontend
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.views import redirect_to_login
|
||||||
|
|
||||||
|
|
||||||
|
class AppLoginRequiredMiddleware:
|
||||||
|
"""Require a logged-in session for the /app/ HTMX UI.
|
||||||
|
|
||||||
|
Deliberately scoped to /app/ only:
|
||||||
|
- /api/ uses DRF token auth (its user isn't resolved until the view runs,
|
||||||
|
so a blanket login check here would wrongly reject valid tokens).
|
||||||
|
- /admin/ has its own login.
|
||||||
|
- the login page and /static/ must stay reachable while logged out.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
if request.path.startswith("/app/") and not request.user.is_authenticated:
|
||||||
|
return redirect_to_login(request.get_full_path(), settings.LOGIN_URL)
|
||||||
|
return self.get_response(request)
|
||||||
@@ -409,6 +409,10 @@
|
|||||||
<a href="{% url 'app-shopping' %}" {% if active_tab == 'shopping' %}class="active"{% endif %}>Shopping</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>
|
<a href="{% url 'app-log' %}" {% if active_tab == 'log' %}class="active"{% endif %}>Cook Log</a>
|
||||||
</div>
|
</div>
|
||||||
|
<form method="post" action="{% url 'logout' %}" style="margin-left: auto;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" style="background: none; border: 1px solid var(--teal-dark); color: var(--grey-light); padding: 0.4rem 0.75rem; border-radius: 4px; font-size: 0.85rem; cursor: pointer;">Log out</button>
|
||||||
|
</form>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Sign in — Kitchen</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-dark: #15131c;
|
||||||
|
--navy: #0e0e5b;
|
||||||
|
--teal-dark: #325664;
|
||||||
|
--sage: #658d89;
|
||||||
|
--red-bright: #f01111;
|
||||||
|
--yellow: #f9df11;
|
||||||
|
--teal-light: #87d1d1;
|
||||||
|
--grey-light: #babcc4;
|
||||||
|
--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;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
background: rgba(50, 86, 100, 0.15);
|
||||||
|
border: 1px solid var(--teal-dark);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.75rem 1.5rem;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--yellow);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
color: var(--teal-light);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(14, 14, 91, 0.3);
|
||||||
|
border: 1px solid var(--teal-dark);
|
||||||
|
color: var(--cream);
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--yellow);
|
||||||
|
box-shadow: 0 0 0 2px rgba(249, 223, 17, 0.15);
|
||||||
|
}
|
||||||
|
.field { margin-bottom: 1rem; }
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--yellow);
|
||||||
|
color: var(--bg-dark);
|
||||||
|
border: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.8rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
.btn:active { transform: translateY(1px); }
|
||||||
|
.errors {
|
||||||
|
background: rgba(240, 17, 17, 0.14);
|
||||||
|
color: var(--red-bright);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form class="login-card" method="post" action="{% url 'login' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="logo">🍳 Kitchen</div>
|
||||||
|
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class="errors">That username and password didn't match. Try again.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="id_username">Username</label>
|
||||||
|
<input type="text" name="username" id="id_username" autocapitalize="none"
|
||||||
|
autocomplete="username" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="id_password">Password</label>
|
||||||
|
<input type="password" name="password" id="id_password"
|
||||||
|
autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<button type="submit" class="btn">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+33
-8
@@ -1,12 +1,39 @@
|
|||||||
from django.test import TestCase, Client
|
from django.test import TestCase, Client
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
from kitchen.models import Ingredient, PantryItem, MetaRecipe, Slot, SlotOption
|
from kitchen.models import Ingredient, PantryItem, MetaRecipe, Slot, SlotOption
|
||||||
|
|
||||||
|
|
||||||
class PantryStateTests(TestCase):
|
class AuthTests(TestCase):
|
||||||
|
def test_app_requires_login(self):
|
||||||
|
resp = self.client.get(reverse("app-pantry"))
|
||||||
|
self.assertEqual(resp.status_code, 302)
|
||||||
|
self.assertIn(reverse("login"), resp.url)
|
||||||
|
|
||||||
|
def test_login_grants_access(self):
|
||||||
|
User.objects.create_user("tom", password="pw")
|
||||||
|
self.assertTrue(self.client.login(username="tom", password="pw"))
|
||||||
|
resp = self.client.get(reverse("app-pantry"))
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
def test_api_is_not_redirected_to_login(self):
|
||||||
|
# /api/ uses DRF token auth; the /app/ login middleware must not touch it.
|
||||||
|
resp = self.client.get("/api/pantry/")
|
||||||
|
self.assertIn(resp.status_code, (401, 403))
|
||||||
|
self.assertNotEqual(resp.status_code, 302)
|
||||||
|
|
||||||
|
|
||||||
|
class _AuthedTestCase(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.client = Client()
|
self.client = Client()
|
||||||
|
self.user = User.objects.create_user("tester", password="pw")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
|
||||||
|
class PantryStateTests(_AuthedTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
self.eggs = Ingredient.objects.create(name="eggs", default_unit="items")
|
self.eggs = Ingredient.objects.create(name="eggs", default_unit="items")
|
||||||
self.item = PantryItem.objects.create(
|
self.item = PantryItem.objects.create(
|
||||||
ingredient=self.eggs, location="fridge", state="in"
|
ingredient=self.eggs, location="fridge", state="in"
|
||||||
@@ -56,7 +83,6 @@ class PantryStateTests(TestCase):
|
|||||||
reverse("app-pantry-add"),
|
reverse("app-pantry-add"),
|
||||||
{"ingredient_name": "eggs", "location": "fridge"},
|
{"ingredient_name": "eggs", "location": "fridge"},
|
||||||
)
|
)
|
||||||
# No duplicate row, and the existing one is back in stock.
|
|
||||||
items = PantryItem.objects.filter(ingredient=self.eggs, location="fridge")
|
items = PantryItem.objects.filter(ingredient=self.eggs, location="fridge")
|
||||||
self.assertEqual(items.count(), 1)
|
self.assertEqual(items.count(), 1)
|
||||||
self.assertEqual(items.first().state, "in")
|
self.assertEqual(items.first().state, "in")
|
||||||
@@ -68,12 +94,12 @@ class PantryStateTests(TestCase):
|
|||||||
self.assertContains(resp, "eggs")
|
self.assertContains(resp, "eggs")
|
||||||
|
|
||||||
|
|
||||||
class RecipesPresenceTests(TestCase):
|
class RecipesPresenceTests(_AuthedTestCase):
|
||||||
"""The recipes matcher is presence-based now: having an ingredient (not
|
"""Presence-based matcher: having an ingredient (not 'out') satisfies a slot
|
||||||
'out') satisfies a slot regardless of quantity."""
|
regardless of quantity."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.client = Client()
|
super().setUp()
|
||||||
self.noodles = Ingredient.objects.create(name="noodles", default_unit="nests")
|
self.noodles = Ingredient.objects.create(name="noodles", default_unit="nests")
|
||||||
self.mr = MetaRecipe.objects.create(name="Noodles", method="boil")
|
self.mr = MetaRecipe.objects.create(name="Noodles", method="boil")
|
||||||
slot = Slot.objects.create(meta_recipe=self.mr, name="carb", required=True)
|
slot = Slot.objects.create(meta_recipe=self.mr, name="carb", required=True)
|
||||||
@@ -82,7 +108,6 @@ class RecipesPresenceTests(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_present_ingredient_no_quantity_is_available(self):
|
def test_present_ingredient_no_quantity_is_available(self):
|
||||||
# 'in' with no quantity should still count as available.
|
|
||||||
PantryItem.objects.create(
|
PantryItem.objects.create(
|
||||||
ingredient=self.noodles, location="cupboard", state="in"
|
ingredient=self.noodles, location="cupboard", state="in"
|
||||||
)
|
)
|
||||||
@@ -98,7 +123,7 @@ class RecipesPresenceTests(TestCase):
|
|||||||
self.assertEqual(resp.status_code, 200)
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
class PageSmokeTests(TestCase):
|
class PageSmokeTests(_AuthedTestCase):
|
||||||
def test_pages_render(self):
|
def test_pages_render(self):
|
||||||
for name in ("app-pantry", "app-recipes", "app-shopping", "app-log"):
|
for name in ("app-pantry", "app-recipes", "app-shopping", "app-log"):
|
||||||
with self.subTest(page=name):
|
with self.subTest(page=name):
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from decimal import Decimal
|
|||||||
|
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
from django.views.decorators.http import require_POST, require_http_methods
|
from django.views.decorators.http import require_POST, require_http_methods
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
@@ -221,7 +220,6 @@ def log_page(request):
|
|||||||
|
|
||||||
# --- HTMX Actions ---
|
# --- HTMX Actions ---
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@require_POST
|
@require_POST
|
||||||
def pantry_add(request):
|
def pantry_add(request):
|
||||||
"""Add an item (or restock an existing one to 'in'). Quantity is optional."""
|
"""Add an item (or restock an existing one to 'in'). Quantity is optional."""
|
||||||
@@ -264,7 +262,6 @@ def pantry_add(request):
|
|||||||
return render(request, "kitchen/partials/pantry_table.html", _pantry_context())
|
return render(request, "kitchen/partials/pantry_table.html", _pantry_context())
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@require_POST
|
@require_POST
|
||||||
def pantry_set_state(request, item_id):
|
def pantry_set_state(request, item_id):
|
||||||
"""Set an item's In/Low/Out state — the primary pantry interaction."""
|
"""Set an item's In/Low/Out state — the primary pantry interaction."""
|
||||||
@@ -289,7 +286,6 @@ def pantry_search(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def pantry_delete(request, item_id):
|
def pantry_delete(request, item_id):
|
||||||
item = get_object_or_404(PantryItem, id=item_id)
|
item = get_object_or_404(PantryItem, id=item_id)
|
||||||
@@ -298,7 +294,6 @@ def pantry_delete(request, item_id):
|
|||||||
return render(request, "kitchen/partials/pantry_table.html", ctx)
|
return render(request, "kitchen/partials/pantry_table.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@require_POST
|
@require_POST
|
||||||
def pantry_move(request, item_id):
|
def pantry_move(request, item_id):
|
||||||
"""Move an item between fridge / freezer / cupboard."""
|
"""Move an item between fridge / freezer / cupboard."""
|
||||||
@@ -322,7 +317,6 @@ def pantry_move(request, item_id):
|
|||||||
return render(request, "kitchen/partials/pantry_table.html", _pantry_context())
|
return render(request, "kitchen/partials/pantry_table.html", _pantry_context())
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@require_POST
|
@require_POST
|
||||||
def pantry_save_expiry(request, item_id):
|
def pantry_save_expiry(request, item_id):
|
||||||
"""Set or clear an item's expiry date (inline editor in the item menu)."""
|
"""Set or clear an item's expiry date (inline editor in the item menu)."""
|
||||||
@@ -332,7 +326,6 @@ def pantry_save_expiry(request, item_id):
|
|||||||
return render(request, "kitchen/partials/pantry_table.html", _pantry_context())
|
return render(request, "kitchen/partials/pantry_table.html", _pantry_context())
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@require_POST
|
@require_POST
|
||||||
def shopping_generate(request):
|
def shopping_generate(request):
|
||||||
"""Generate smart shopping list and return updated HTML."""
|
"""Generate smart shopping list and return updated HTML."""
|
||||||
@@ -420,7 +413,6 @@ def shopping_generate(request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@require_POST
|
@require_POST
|
||||||
def shopping_toggle(request, item_id):
|
def shopping_toggle(request, item_id):
|
||||||
item = get_object_or_404(ShoppingListItem, id=item_id)
|
item = get_object_or_404(ShoppingListItem, id=item_id)
|
||||||
@@ -430,7 +422,6 @@ def shopping_toggle(request, item_id):
|
|||||||
return render(request, "kitchen/partials/shopping_list.html", {"items": _shopping_list_items()})
|
return render(request, "kitchen/partials/shopping_list.html", {"items": _shopping_list_items()})
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@require_POST
|
@require_POST
|
||||||
def shopping_clear(request):
|
def shopping_clear(request):
|
||||||
ShoppingListItem.objects.filter(checked=True).delete()
|
ShoppingListItem.objects.filter(checked=True).delete()
|
||||||
|
|||||||
Reference in New Issue
Block a user