diff options
Diffstat (limited to 'kitchen')
| -rw-r--r-- | kitchen/middleware.py | 21 | ||||
| -rw-r--r-- | kitchen/templates/kitchen/base.html | 4 | ||||
| -rw-r--r-- | kitchen/templates/registration/login.html | 116 | ||||
| -rw-r--r-- | kitchen/tests.py | 41 | ||||
| -rw-r--r-- | kitchen/views_htmx.py | 9 |
5 files changed, 174 insertions, 17 deletions
diff --git a/kitchen/middleware.py b/kitchen/middleware.py new file mode 100644 index 0000000..63c2f4f --- /dev/null +++ b/kitchen/middleware.py @@ -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) diff --git a/kitchen/templates/kitchen/base.html b/kitchen/templates/kitchen/base.html index 323e985..92faedc 100644 --- a/kitchen/templates/kitchen/base.html +++ b/kitchen/templates/kitchen/base.html @@ -409,6 +409,10 @@ <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> + <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> <div class="container"> diff --git a/kitchen/templates/registration/login.html b/kitchen/templates/registration/login.html new file mode 100644 index 0000000..4d0e1aa --- /dev/null +++ b/kitchen/templates/registration/login.html @@ -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> diff --git a/kitchen/tests.py b/kitchen/tests.py index 31e0665..065d530 100644 --- a/kitchen/tests.py +++ b/kitchen/tests.py @@ -1,12 +1,39 @@ from django.test import TestCase, Client from django.urls import reverse +from django.contrib.auth.models import User 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): 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.item = PantryItem.objects.create( ingredient=self.eggs, location="fridge", state="in" @@ -56,7 +83,6 @@ class PantryStateTests(TestCase): reverse("app-pantry-add"), {"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") self.assertEqual(items.count(), 1) self.assertEqual(items.first().state, "in") @@ -68,12 +94,12 @@ class PantryStateTests(TestCase): self.assertContains(resp, "eggs") -class RecipesPresenceTests(TestCase): - """The recipes matcher is presence-based now: having an ingredient (not - 'out') satisfies a slot regardless of quantity.""" +class RecipesPresenceTests(_AuthedTestCase): + """Presence-based matcher: having an ingredient (not 'out') satisfies a slot + regardless of quantity.""" def setUp(self): - self.client = Client() + super().setUp() self.noodles = Ingredient.objects.create(name="noodles", default_unit="nests") self.mr = MetaRecipe.objects.create(name="Noodles", method="boil") 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): - # 'in' with no quantity should still count as available. PantryItem.objects.create( ingredient=self.noodles, location="cupboard", state="in" ) @@ -98,7 +123,7 @@ class RecipesPresenceTests(TestCase): self.assertEqual(resp.status_code, 200) -class PageSmokeTests(TestCase): +class PageSmokeTests(_AuthedTestCase): def test_pages_render(self): for name in ("app-pantry", "app-recipes", "app-shopping", "app-log"): with self.subTest(page=name): diff --git a/kitchen/views_htmx.py b/kitchen/views_htmx.py index 21eec8a..9e1838e 100644 --- a/kitchen/views_htmx.py +++ b/kitchen/views_htmx.py @@ -7,7 +7,6 @@ from decimal import Decimal from django.http import HttpResponse 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 .models import ( @@ -221,7 +220,6 @@ def log_page(request): # --- HTMX Actions --- -@csrf_exempt @require_POST def pantry_add(request): """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()) -@csrf_exempt @require_POST def pantry_set_state(request, item_id): """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"]) def pantry_delete(request, 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) -@csrf_exempt @require_POST def pantry_move(request, item_id): """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()) -@csrf_exempt @require_POST def pantry_save_expiry(request, item_id): """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()) -@csrf_exempt @require_POST def shopping_generate(request): """Generate smart shopping list and return updated HTML.""" @@ -420,7 +413,6 @@ def shopping_generate(request): }) -@csrf_exempt @require_POST def shopping_toggle(request, 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()}) -@csrf_exempt @require_POST def shopping_clear(request): ShoppingListItem.objects.filter(checked=True).delete() |
