Add phase 2: recipes with visibility, photos, search and favourites
- Recipe model with ingredients (amount/range, unit, group, note), steps, lowercase tags, author notes, source URL and private/public visibility - Central RecipeQuerySet.visible_to() used by every read path; edit only by owner, delete by owner or admin (visible recipes only) - Text-based editing: ingredient/step parser with roundtrip and scaling - Photo upload (JPEG/PNG/WebP/HEIC), re-encoded to WebP without EXIF, thumbnails, cover image, served only through permission-checked view - Search across title, description, ingredients, tags; ingredient and tag filters; "mine" and favourites views - Admin can delete all recipes of a deactivated user - Acceptance tests: private recipes unreachable for others and admins in list, search, tags, favourites, detail, edit, delete and image URLs Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ef9deaeff7
commit
6f7e4f92ca
@@ -49,3 +49,7 @@ GUNICORN_TIMEOUT=120
|
||||
LOGIN_MAX_FAILURES_PER_EMAIL=5
|
||||
LOGIN_MAX_FAILURES_PER_IP=30
|
||||
LOGIN_LOCKOUT_MINUTES=15
|
||||
|
||||
# --- Fotos ---------------------------------------------------------------------
|
||||
IMAGE_MAX_UPLOAD_MB=15
|
||||
IMAGE_MAX_PER_RECIPE=10
|
||||
|
||||
@@ -6,15 +6,16 @@ port; TLS, domain and proxy are out of scope.
|
||||
|
||||
## Status
|
||||
- [x] Phase 1: Docker setup, DB, auth with invitations, roles
|
||||
- [ ] Phase 2: Recipes CRUD, visibility (private/public), photos, notes, search, favourites
|
||||
- [x] Phase 2: Recipes CRUD, visibility (private/public), photos, notes, search, favourites
|
||||
- [ ] Phase 3: Comments, serving calculator, cooking mode (wake lock)
|
||||
- [ ] Phase 4: AI (OpenAI): clean up manual input + photo/screenshot import
|
||||
- [ ] Phase 5: Link import (schema.org JSON-LD, Instagram with paste fallback, Android PWA share target)
|
||||
- [ ] Phase 6: Backup script, full README
|
||||
|
||||
## Stack
|
||||
Django 5.2 LTS (Python 3.12), server-rendered templates, HTMX planned for Phase 2+, PostgreSQL 17,
|
||||
gunicorn (gthread), WhiteNoise for static files. No Redis, no JS build step.
|
||||
Django 5.2 LTS (Python 3.12), server-rendered templates, PostgreSQL 17,
|
||||
gunicorn (gthread), WhiteNoise for static files, Pillow + pillow-heif for photos. No Redis, no HTMX,
|
||||
no JS build step: plain forms with small progressive enhancements in `static/js/app.js`.
|
||||
|
||||
## Layout
|
||||
- `config/` – settings (all env-driven, see `.env.example`), urls, wsgi. `settings_test.py` for pytest.
|
||||
@@ -24,6 +25,11 @@ gunicorn (gthread), WhiteNoise for static files. No Redis, no JS build step.
|
||||
- `accounts/` – custom `User` (e-mail login, `role` = user|admin), `Invitation`, `PasswordResetToken`,
|
||||
login/registration/reset views, admin area (`admin_views.py`, `/verwaltung/…`),
|
||||
`ensure_admin` management command (first admin from env).
|
||||
- `recipes/` – `Recipe` (+ `Ingredient`, `Step`, `Tag`, `RecipeImage`, `Favorite`). `RecipeQuerySet.visible_to/
|
||||
editable_by/deletable_by/search` hold all access rules. `parsing.py` converts the text form fields
|
||||
(one ingredient per line, `Gruppe:` headers, steps separated by blank lines) to structured data and
|
||||
scales amounts. `images.py` validates/re-encodes uploads (WebP 1600 px + 480 px thumb, no EXIF).
|
||||
Photos are served only via `recipes:photo` (`/bilder/<uuid>/<gross|klein>.webp`) after a visibility check.
|
||||
- `templates/`, `static/` – global templates, `app.css` (mobile first, dark mode), `app.js`.
|
||||
- `docker/entrypoint.sh` – migrate → ensure_admin → purge_ratelimits → gunicorn.
|
||||
- `scripts/` – `init-env.sh` (creates .env with secrets), `dev_server.py` (local run without Docker).
|
||||
|
||||
@@ -103,6 +103,25 @@ def user_action(request, pk, action):
|
||||
return redirect("accounts:admin_users")
|
||||
|
||||
|
||||
@admin_required
|
||||
@never_cache
|
||||
def user_delete_recipes(request, pk):
|
||||
"""Delete all recipes of a deactivated user, including private ones (content is never shown)."""
|
||||
from recipes.models import Recipe
|
||||
from recipes.views import delete_recipe
|
||||
|
||||
target = get_object_or_404(User, pk=pk, is_active=False)
|
||||
recipes = Recipe.objects.filter(owner=target)
|
||||
if request.method == "POST":
|
||||
count = 0
|
||||
for recipe in recipes:
|
||||
delete_recipe(recipe)
|
||||
count += 1
|
||||
messages.success(request, f"{count} Rezepte von {target} gelöscht.")
|
||||
return redirect("accounts:admin_users")
|
||||
return render(request, "accounts/admin/delete_recipes.html", {"target": target, "count": recipes.count()})
|
||||
|
||||
|
||||
@admin_required
|
||||
@never_cache
|
||||
def invitation_list(request):
|
||||
|
||||
+2
-1
@@ -12,7 +12,8 @@ urlpatterns = [
|
||||
path("konto/", views.profile, name="profile"),
|
||||
path("konto/passwort/", views.password_change, name="password_change"),
|
||||
path("verwaltung/nutzer/", admin_views.user_list, name="admin_users"),
|
||||
path("verwaltung/nutzer/<int:pk>/<str:action>/", admin_views.user_action, name="admin_user_action"),
|
||||
path("verwaltung/nutzer/<int:pk>/rezepte-loeschen/", admin_views.user_delete_recipes, name="admin_user_delete_recipes"),
|
||||
path("verwaltung/nutzer/<int:pk>/<str:action>/",admin_views.user_action, name="admin_user_action"),
|
||||
path("verwaltung/einladungen/", admin_views.invitation_list, name="admin_invitations"),
|
||||
path("verwaltung/einladungen/<int:pk>/widerrufen/", admin_views.invitation_revoke, name="admin_invitation_revoke"),
|
||||
]
|
||||
|
||||
+4
-4
@@ -38,7 +38,7 @@ def _safe_next(request):
|
||||
@never_cache
|
||||
def login_view(request):
|
||||
if request.user.is_authenticated:
|
||||
return redirect("core:home")
|
||||
return redirect("recipes:list")
|
||||
form = LoginForm(request.POST or None)
|
||||
status = 200
|
||||
if request.method == "POST" and form.is_valid():
|
||||
@@ -100,7 +100,7 @@ def register(request, token):
|
||||
invitation.save(update_fields=["used_at", "used_by"])
|
||||
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
|
||||
messages.success(request, "Willkommen im Kochbuch!")
|
||||
return redirect("core:home")
|
||||
return redirect("recipes:list")
|
||||
return render(request, "accounts/register.html", {"form": form, "invitation": invitation})
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ def password_reset(request, token):
|
||||
reset.save(update_fields=["used_at"])
|
||||
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
|
||||
messages.success(request, "Dein Passwort wurde geändert.")
|
||||
return redirect("core:home")
|
||||
return redirect("recipes:list")
|
||||
return render(request, "accounts/password_reset.html", {"form": form, "reset_user": reset.user})
|
||||
|
||||
|
||||
@@ -152,5 +152,5 @@ def password_change(request):
|
||||
user.save(update_fields=["password", "must_change_password"])
|
||||
update_session_auth_hash(request, user)
|
||||
messages.success(request, "Dein Passwort wurde geändert.")
|
||||
return redirect("core:home")
|
||||
return redirect("recipes:list")
|
||||
return render(request, "accounts/password_change.html", {"form": form, "forced": user.must_change_password})
|
||||
|
||||
+7
-1
@@ -59,6 +59,7 @@ INSTALLED_APPS = [
|
||||
"django.contrib.staticfiles",
|
||||
"core",
|
||||
"accounts",
|
||||
"recipes",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@@ -117,7 +118,7 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
AUTH_USER_MODEL = "accounts.User"
|
||||
LOGIN_URL = "accounts:login"
|
||||
LOGIN_REDIRECT_URL = "core:home"
|
||||
LOGIN_REDIRECT_URL = "recipes:list"
|
||||
LOGOUT_REDIRECT_URL = "accounts:login"
|
||||
|
||||
PASSWORD_HASHERS = [
|
||||
@@ -135,6 +136,11 @@ LOGIN_MAX_FAILURES_PER_EMAIL = env_int("LOGIN_MAX_FAILURES_PER_EMAIL", 5)
|
||||
LOGIN_MAX_FAILURES_PER_IP = env_int("LOGIN_MAX_FAILURES_PER_IP", 30)
|
||||
LOGIN_LOCKOUT_MINUTES = env_int("LOGIN_LOCKOUT_MINUTES", 15)
|
||||
|
||||
IMAGE_MAX_UPLOAD_MB = env_int("IMAGE_MAX_UPLOAD_MB", 15)
|
||||
IMAGE_MAX_PER_RECIPE = env_int("IMAGE_MAX_PER_RECIPE", 10)
|
||||
# Request body limit for non-file fields; files are limited per image above.
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024
|
||||
|
||||
INVITATION_VALID_DAYS = 7
|
||||
PASSWORD_RESET_VALID_HOURS = 24
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.urls import include, path
|
||||
urlpatterns = [
|
||||
path("", include("core.urls")),
|
||||
path("", include("accounts.urls")),
|
||||
path("", include("recipes.urls")),
|
||||
]
|
||||
|
||||
handler403 = "core.views.error_403"
|
||||
|
||||
@@ -5,6 +5,5 @@ from . import views
|
||||
app_name = "core"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.home, name="home"),
|
||||
path("healthz", views.healthz, name="healthz"),
|
||||
]
|
||||
|
||||
@@ -6,10 +6,6 @@ from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.http import require_GET
|
||||
|
||||
|
||||
def home(request):
|
||||
return render(request, "core/home.html")
|
||||
|
||||
|
||||
@login_not_required
|
||||
@never_cache
|
||||
@require_GET
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RecipesConfig(AppConfig):
|
||||
name = "recipes"
|
||||
verbose_name = "Rezepte"
|
||||
@@ -0,0 +1,137 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
from . import images, parsing
|
||||
from .models import Recipe
|
||||
|
||||
|
||||
class MultipleFileInput(forms.ClearableFileInput):
|
||||
allow_multiple_selected = True
|
||||
|
||||
|
||||
class MultipleImageField(forms.FileField):
|
||||
widget = MultipleFileInput
|
||||
|
||||
def clean(self, data, initial=None):
|
||||
files = data if isinstance(data, (list, tuple)) else ([data] if data else [])
|
||||
if len(files) > settings.IMAGE_MAX_PER_RECIPE:
|
||||
raise forms.ValidationError(f"Höchstens {settings.IMAGE_MAX_PER_RECIPE} Bilder auf einmal.")
|
||||
cleaned = []
|
||||
for f in files:
|
||||
f = super().clean(f, initial)
|
||||
try:
|
||||
images.validate(f)
|
||||
except images.ImageError as error:
|
||||
raise forms.ValidationError(f"{f.name}: {error}") from None
|
||||
cleaned.append(f)
|
||||
return cleaned
|
||||
|
||||
|
||||
def photo_field(label="Fotos"):
|
||||
return MultipleImageField(
|
||||
label=label,
|
||||
required=False,
|
||||
widget=MultipleFileInput(attrs={"accept": "image/*"}),
|
||||
help_text=f"JPEG, PNG, WebP oder HEIC, max. {settings.IMAGE_MAX_UPLOAD_MB} MB pro Bild. "
|
||||
"Am Handy kannst du direkt die Kamera wählen.",
|
||||
)
|
||||
|
||||
|
||||
class RecipeForm(forms.ModelForm):
|
||||
ingredients_text = forms.CharField(
|
||||
label="Zutaten",
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={"rows": 10, "placeholder": "200 g Mehl\n2 Eier\n1 Prise Salz\n\nFür die Soße:\n1 Dose Tomaten"}),
|
||||
help_text="Eine Zutat pro Zeile. Gruppen mit Doppelpunkt am Ende, z. B. „Für die Soße:“.",
|
||||
)
|
||||
steps_text = forms.CharField(
|
||||
label="Zubereitung",
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={"rows": 10, "placeholder": "Ofen auf 180 °C vorheizen.\n\nMehl und Eier verrühren."}),
|
||||
help_text="Schritte durch eine Leerzeile trennen (ohne Leerzeilen ist jede Zeile ein Schritt).",
|
||||
)
|
||||
tags_text = forms.CharField(
|
||||
label="Tags",
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={"placeholder": "pasta, vegetarisch, schnell", "autocapitalize": "none"}),
|
||||
help_text="Kommagetrennt, werden kleingeschrieben gespeichert.",
|
||||
)
|
||||
source_url = forms.URLField(label="Quelle (URL)", required=False, max_length=1000, assume_scheme="https")
|
||||
photos = photo_field()
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ["title", "description", "servings", "servings_label", "prep_minutes", "cook_minutes",
|
||||
"visibility", "source_url", "author_notes"]
|
||||
widgets = {
|
||||
"description": forms.Textarea(attrs={"rows": 3}),
|
||||
"author_notes": forms.Textarea(attrs={"rows": 3, "placeholder": "Tipps, Abwandlungen, was beim nächsten Mal anders …"}),
|
||||
"servings": forms.NumberInput(attrs={"min": 1, "max": 100, "inputmode": "numeric"}),
|
||||
"prep_minutes": forms.NumberInput(attrs={"min": 0, "inputmode": "numeric"}),
|
||||
"cook_minutes": forms.NumberInput(attrs={"min": 0, "inputmode": "numeric"}),
|
||||
"visibility": forms.RadioSelect,
|
||||
}
|
||||
|
||||
field_order = ["title", "description", "servings", "servings_label", "prep_minutes", "cook_minutes",
|
||||
"ingredients_text", "steps_text", "tags_text", "photos", "author_notes", "source_url", "visibility"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["servings"].min_value = 1
|
||||
recipe = self.instance
|
||||
if recipe.pk and not self.is_bound:
|
||||
self.initial["ingredients_text"] = parsing.ingredients_to_text(recipe.ingredients.all())
|
||||
self.initial["steps_text"] = parsing.steps_to_text([s.text for s in recipe.steps.all()])
|
||||
self.initial["tags_text"] = ", ".join(t.name for t in recipe.tags.all())
|
||||
if recipe.pk:
|
||||
remaining = settings.IMAGE_MAX_PER_RECIPE - recipe.images.count()
|
||||
self.fields["photos"].label = "Weitere Fotos"
|
||||
if remaining <= 0:
|
||||
del self.fields["photos"]
|
||||
|
||||
def clean_servings(self):
|
||||
servings = self.cleaned_data["servings"]
|
||||
if not servings or servings < 1 or servings > 100:
|
||||
raise forms.ValidationError("Bitte eine Zahl zwischen 1 und 100 angeben.")
|
||||
return servings
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
cleaned["ingredients"] = parsing.parse_ingredients(cleaned.get("ingredients_text", ""))
|
||||
cleaned["steps"] = parsing.parse_steps(cleaned.get("steps_text", ""))
|
||||
cleaned["tags"] = parsing.parse_tags(cleaned.get("tags_text", ""))
|
||||
photos = cleaned.get("photos") or []
|
||||
if self.instance.pk and photos:
|
||||
if self.instance.images.count() + len(photos) > settings.IMAGE_MAX_PER_RECIPE:
|
||||
self.add_error("photos", f"Ein Rezept kann höchstens {settings.IMAGE_MAX_PER_RECIPE} Bilder haben.")
|
||||
return cleaned
|
||||
|
||||
@transaction.atomic
|
||||
def save(self, owner=None):
|
||||
recipe = super().save(commit=False)
|
||||
if owner is not None and not recipe.pk:
|
||||
recipe.owner = owner
|
||||
recipe.save()
|
||||
recipe.set_ingredients(self.cleaned_data["ingredients"])
|
||||
recipe.set_steps(self.cleaned_data["steps"])
|
||||
recipe.set_tags(self.cleaned_data["tags"])
|
||||
return recipe
|
||||
|
||||
|
||||
class PhotoUploadForm(forms.Form):
|
||||
photos = photo_field("Fotos hinzufügen")
|
||||
|
||||
|
||||
class SearchForm(forms.Form):
|
||||
q = forms.CharField(label="Suche", required=False, widget=forms.SearchInput(attrs={"placeholder": "Titel, Zutat, Tag …"}))
|
||||
zutat = forms.CharField(label="Mit Zutaten", required=False, widget=forms.TextInput(attrs={"placeholder": "z. B. zucchini, feta"}))
|
||||
tag = forms.CharField(required=False, widget=forms.HiddenInput)
|
||||
ansicht = forms.ChoiceField(
|
||||
label="Anzeigen",
|
||||
required=False,
|
||||
choices=[("", "Alle Rezepte"), ("meine", "Meine Rezepte"), ("favoriten", "Favoriten")],
|
||||
)
|
||||
|
||||
def ingredient_terms(self):
|
||||
return [t.strip() for t in self.cleaned_data.get("zutat", "").split(",") if t.strip()]
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Validation and processing of uploaded photos.
|
||||
|
||||
Uploads are decoded with Pillow (HEIC via pillow-heif), auto-rotated, stripped of all
|
||||
metadata (EXIF/GPS) by re-encoding, downscaled and stored as WebP under MEDIA_ROOT.
|
||||
The original upload is never stored.
|
||||
"""
|
||||
|
||||
import io
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pillow_heif
|
||||
from django.conf import settings
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
|
||||
pillow_heif.register_heif_opener()
|
||||
|
||||
ALLOWED_FORMATS = {"JPEG", "MPO", "PNG", "WEBP", "HEIF"}
|
||||
MAX_PIXELS = 50_000_000
|
||||
LARGE_SIZE = 1600
|
||||
THUMB_SIZE = 480
|
||||
AI_SIZE = 2000
|
||||
|
||||
Image.MAX_IMAGE_PIXELS = MAX_PIXELS
|
||||
|
||||
# Decoding a 12 MP photo needs ~100 MB; one at a time per process keeps memory predictable.
|
||||
_processing = threading.BoundedSemaphore(1)
|
||||
|
||||
|
||||
class ImageError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def max_upload_bytes():
|
||||
return settings.IMAGE_MAX_UPLOAD_MB * 1024 * 1024
|
||||
|
||||
|
||||
def _open(fileobj):
|
||||
size = getattr(fileobj, "size", None)
|
||||
if size is not None and size > max_upload_bytes():
|
||||
raise ImageError(f"Das Bild ist zu groß (max. {settings.IMAGE_MAX_UPLOAD_MB} MB).")
|
||||
try:
|
||||
fileobj.seek(0)
|
||||
img = Image.open(fileobj)
|
||||
except (UnidentifiedImageError, OSError, Image.DecompressionBombError):
|
||||
raise ImageError("Die Datei ist kein unterstütztes Bild (JPEG, PNG, WebP oder HEIC).") from None
|
||||
if img.format not in ALLOWED_FORMATS:
|
||||
raise ImageError("Die Datei ist kein unterstütztes Bild (JPEG, PNG, WebP oder HEIC).")
|
||||
if img.width * img.height > MAX_PIXELS:
|
||||
raise ImageError("Das Bild hat zu viele Pixel.")
|
||||
return img
|
||||
|
||||
|
||||
def validate(fileobj):
|
||||
"""Cheap check (header only) used during form validation."""
|
||||
_open(fileobj)
|
||||
fileobj.seek(0)
|
||||
|
||||
|
||||
def _decode(fileobj, target):
|
||||
img = _open(fileobj)
|
||||
if img.format == "JPEG":
|
||||
img.draft("RGB", (target * 2, target * 2)) # decode at reduced size: much less memory
|
||||
try:
|
||||
img.load()
|
||||
except (OSError, Image.DecompressionBombError):
|
||||
raise ImageError("Das Bild ist beschädigt oder unvollständig.") from None
|
||||
img = ImageOps.exif_transpose(img)
|
||||
if img.mode in ("RGBA", "LA", "P"):
|
||||
img = img.convert("RGBA")
|
||||
background = Image.new("RGB", img.size, (255, 255, 255))
|
||||
background.paste(img, mask=img.getchannel("A"))
|
||||
img = background
|
||||
elif img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
return img
|
||||
|
||||
|
||||
def _media_path(relative):
|
||||
path = Path(settings.MEDIA_ROOT) / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def process_upload(fileobj):
|
||||
"""Store a large and a thumbnail WebP. Returns dict with file, thumb, width, height."""
|
||||
with _processing:
|
||||
img = _decode(fileobj, LARGE_SIZE)
|
||||
name = uuid.uuid4().hex
|
||||
large = img.copy()
|
||||
large.thumbnail((LARGE_SIZE, LARGE_SIZE), Image.LANCZOS)
|
||||
thumb = img.copy()
|
||||
thumb.thumbnail((THUMB_SIZE, THUMB_SIZE), Image.LANCZOS)
|
||||
result = {"file": f"recipes/{name}.webp", "thumb": f"recipes/{name}_t.webp",
|
||||
"width": large.width, "height": large.height}
|
||||
large.save(_media_path(result["file"]), "WEBP", quality=82, method=4)
|
||||
thumb.save(_media_path(result["thumb"]), "WEBP", quality=78, method=4)
|
||||
return result
|
||||
|
||||
|
||||
def prepare_for_ai(fileobj):
|
||||
"""Downscaled JPEG bytes (no metadata) for sending to a vision model."""
|
||||
with _processing:
|
||||
img = _decode(fileobj, AI_SIZE)
|
||||
img.thumbnail((AI_SIZE, AI_SIZE), Image.LANCZOS)
|
||||
out = io.BytesIO()
|
||||
img.save(out, "JPEG", quality=85)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def delete_files(*relative_paths):
|
||||
for relative in relative_paths:
|
||||
if relative:
|
||||
(Path(settings.MEDIA_ROOT) / relative).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def file_path(relative):
|
||||
"""Absolute path of a stored file; refuses anything outside MEDIA_ROOT."""
|
||||
root = Path(settings.MEDIA_ROOT).resolve()
|
||||
path = (root / relative).resolve()
|
||||
if root not in path.parents:
|
||||
raise ImageError("Ungültiger Pfad.")
|
||||
return path
|
||||
@@ -0,0 +1,114 @@
|
||||
# Generated by Django 5.2.18 on 2026-10-30 20:33
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Tag',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=40, unique=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Recipe',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200, verbose_name='Titel')),
|
||||
('description', models.TextField(blank=True, verbose_name='Beschreibung')),
|
||||
('servings', models.PositiveSmallIntegerField(default=4, verbose_name='Portionen')),
|
||||
('servings_label', models.CharField(blank=True, default='Portionen', max_length=30, verbose_name='Einheit der Portionen')),
|
||||
('prep_minutes', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Zubereitungszeit (Min.)')),
|
||||
('cook_minutes', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Koch-/Backzeit (Min.)')),
|
||||
('source_url', models.URLField(blank=True, max_length=1000, verbose_name='Quelle (URL)')),
|
||||
('author_notes', models.TextField(blank=True, verbose_name='Notizen')),
|
||||
('visibility', models.CharField(choices=[('private', 'Privat (nur für mich)'), ('public', 'Öffentlich (alle Nutzer)')], default='private', max_length=10, verbose_name='Sichtbarkeit')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Geändert')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recipes', to=settings.AUTH_USER_MODEL, verbose_name='Autor')),
|
||||
('tags', models.ManyToManyField(blank=True, related_name='recipes', to='recipes.tag')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-updated_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Ingredient',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('position', models.PositiveSmallIntegerField(default=0)),
|
||||
('group', models.CharField(blank=True, max_length=100)),
|
||||
('amount', models.DecimalField(blank=True, decimal_places=3, max_digits=10, null=True)),
|
||||
('amount_max', models.DecimalField(blank=True, decimal_places=3, max_digits=10, null=True)),
|
||||
('unit', models.CharField(blank=True, max_length=30)),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('note', models.CharField(blank=True, max_length=200)),
|
||||
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='recipes.recipe')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['position'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RecipeImage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('file', models.CharField(max_length=200)),
|
||||
('thumb', models.CharField(max_length=200)),
|
||||
('width', models.PositiveIntegerField(default=0)),
|
||||
('height', models.PositiveIntegerField(default=0)),
|
||||
('is_cover', models.BooleanField(default=False)),
|
||||
('position', models.PositiveSmallIntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='recipes.recipe')),
|
||||
('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-is_cover', 'position', 'created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Step',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('position', models.PositiveSmallIntegerField(default=0)),
|
||||
('text', models.TextField()),
|
||||
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='steps', to='recipes.recipe')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['position'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Favorite',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to=settings.AUTH_USER_MODEL)),
|
||||
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by', to='recipes.recipe')),
|
||||
],
|
||||
options={
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'recipe'), name='favorite_unique')],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='recipe',
|
||||
index=models.Index(fields=['visibility', 'owner'], name='recipes_rec_visibil_1325f0_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,199 @@
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
from . import parsing
|
||||
|
||||
|
||||
class RecipeQuerySet(models.QuerySet):
|
||||
def visible_to(self, user):
|
||||
"""THE access rule: public recipes for every logged-in user, private ones only for the owner.
|
||||
|
||||
Every read path (list, detail, search, tags, images, comments, favourites) must start here.
|
||||
Admins get no exception.
|
||||
"""
|
||||
if user is None or not user.is_authenticated:
|
||||
return self.none()
|
||||
return self.filter(Q(visibility=Recipe.Visibility.PUBLIC) | Q(owner=user))
|
||||
|
||||
def editable_by(self, user):
|
||||
if user is None or not user.is_authenticated:
|
||||
return self.none()
|
||||
return self.filter(owner=user)
|
||||
|
||||
def deletable_by(self, user):
|
||||
if user is None or not user.is_authenticated:
|
||||
return self.none()
|
||||
if user.is_admin:
|
||||
return self.visible_to(user)
|
||||
return self.editable_by(user)
|
||||
|
||||
def search(self, query="", ingredients=(), tags=()):
|
||||
"""Every word of the query must appear in title, description, an ingredient or a tag."""
|
||||
qs = self
|
||||
for word in query.split():
|
||||
qs = qs.filter(
|
||||
Q(title__icontains=word)
|
||||
| Q(description__icontains=word)
|
||||
| Q(ingredients__name__icontains=word)
|
||||
| Q(tags__name__icontains=word.lower())
|
||||
)
|
||||
for ingredient in ingredients:
|
||||
qs = qs.filter(ingredients__name__icontains=ingredient)
|
||||
for tag in tags:
|
||||
qs = qs.filter(tags__name=parsing.normalize_tag(tag))
|
||||
return qs.distinct()
|
||||
|
||||
|
||||
class Tag(models.Model):
|
||||
name = models.CharField(max_length=parsing.MAX_TAG_LENGTH, unique=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.name = parsing.normalize_tag(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def visible_to(cls, user):
|
||||
"""Only tags that occur on at least one recipe the user may see (no leaks via tag names)."""
|
||||
visible = Recipe.objects.visible_to(user)
|
||||
return cls.objects.filter(recipes__in=visible).distinct()
|
||||
|
||||
|
||||
class Recipe(models.Model):
|
||||
class Visibility(models.TextChoices):
|
||||
PRIVATE = "private", "Privat (nur für mich)"
|
||||
PUBLIC = "public", "Öffentlich (alle Nutzer)"
|
||||
|
||||
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="recipes", verbose_name="Autor")
|
||||
title = models.CharField("Titel", max_length=200)
|
||||
description = models.TextField("Beschreibung", blank=True)
|
||||
servings = models.PositiveSmallIntegerField("Portionen", default=4)
|
||||
servings_label = models.CharField("Einheit der Portionen", max_length=30, default="Portionen", blank=True)
|
||||
prep_minutes = models.PositiveSmallIntegerField("Zubereitungszeit (Min.)", null=True, blank=True)
|
||||
cook_minutes = models.PositiveSmallIntegerField("Koch-/Backzeit (Min.)", null=True, blank=True)
|
||||
source_url = models.URLField("Quelle (URL)", max_length=1000, blank=True)
|
||||
author_notes = models.TextField("Notizen", blank=True)
|
||||
visibility = models.CharField("Sichtbarkeit", max_length=10, choices=Visibility.choices, default=Visibility.PRIVATE)
|
||||
tags = models.ManyToManyField(Tag, blank=True, related_name="recipes")
|
||||
created_at = models.DateTimeField("Erstellt", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("Geändert", auto_now=True)
|
||||
|
||||
objects = RecipeQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ["-updated_at"]
|
||||
indexes = [models.Index(fields=["visibility", "owner"])]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@property
|
||||
def is_public(self):
|
||||
return self.visibility == self.Visibility.PUBLIC
|
||||
|
||||
@property
|
||||
def total_minutes(self):
|
||||
if self.prep_minutes is None and self.cook_minutes is None:
|
||||
return None
|
||||
return (self.prep_minutes or 0) + (self.cook_minutes or 0)
|
||||
|
||||
@property
|
||||
def cover(self):
|
||||
images = list(self.images.all())
|
||||
return next((img for img in images if img.is_cover), images[0] if images else None)
|
||||
|
||||
def set_ingredients(self, items):
|
||||
self.ingredients.all().delete()
|
||||
Ingredient.objects.bulk_create(
|
||||
Ingredient(recipe=self, position=i, group=item.group, amount=item.amount, amount_max=item.amount_max,
|
||||
unit=item.unit[:30], name=item.name[:200], note=item.note[:200])
|
||||
for i, item in enumerate(items)
|
||||
)
|
||||
|
||||
def set_steps(self, texts):
|
||||
self.steps.all().delete()
|
||||
Step.objects.bulk_create(Step(recipe=self, position=i, text=text) for i, text in enumerate(texts))
|
||||
|
||||
def set_tags(self, names):
|
||||
self.tags.set([Tag.objects.get_or_create(name=parsing.normalize_tag(n))[0] for n in names])
|
||||
|
||||
def scaled_ingredients(self, servings):
|
||||
"""Ingredients scaled to the requested servings, grouped: [(group, [display dicts])]."""
|
||||
factor = Decimal(servings) / Decimal(self.servings) if self.servings else Decimal(1)
|
||||
groups = []
|
||||
for ing in self.ingredients.all():
|
||||
amount, amount_max, unit = parsing.scale_amount(ing.amount, ing.amount_max, ing.unit, factor)
|
||||
entry = {
|
||||
"amount": parsing.format_amount(amount)
|
||||
+ (f"–{parsing.format_amount(amount_max)}" if amount_max is not None else ""),
|
||||
"unit": unit,
|
||||
"name": ing.name,
|
||||
"note": ing.note,
|
||||
}
|
||||
if not groups or groups[-1][0] != ing.group:
|
||||
groups.append((ing.group, []))
|
||||
groups[-1][1].append(entry)
|
||||
return groups
|
||||
|
||||
|
||||
class Ingredient(models.Model):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, related_name="ingredients")
|
||||
position = models.PositiveSmallIntegerField(default=0)
|
||||
group = models.CharField(max_length=100, blank=True)
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=3, null=True, blank=True)
|
||||
amount_max = models.DecimalField(max_digits=10, decimal_places=3, null=True, blank=True)
|
||||
unit = models.CharField(max_length=30, blank=True)
|
||||
name = models.CharField(max_length=200)
|
||||
note = models.CharField(max_length=200, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["position"]
|
||||
|
||||
def __str__(self):
|
||||
return parsing.format_ingredient(self.amount, self.amount_max, self.unit, self.name, self.note)
|
||||
|
||||
|
||||
class Step(models.Model):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, related_name="steps")
|
||||
position = models.PositiveSmallIntegerField(default=0)
|
||||
text = models.TextField()
|
||||
|
||||
class Meta:
|
||||
ordering = ["position"]
|
||||
|
||||
|
||||
class RecipeImage(models.Model):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, related_name="images")
|
||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
file = models.CharField(max_length=200)
|
||||
thumb = models.CharField(max_length=200)
|
||||
width = models.PositiveIntegerField(default=0)
|
||||
height = models.PositiveIntegerField(default=0)
|
||||
is_cover = models.BooleanField(default=False)
|
||||
position = models.PositiveSmallIntegerField(default=0)
|
||||
uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="+")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-is_cover", "position", "created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"Bild {self.uuid} zu {self.recipe_id}"
|
||||
|
||||
|
||||
class Favorite(models.Model):
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="favorites")
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, related_name="favorited_by")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [models.UniqueConstraint(fields=["user", "recipe"], name="favorite_unique")]
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Parsing and formatting of ingredient lines, steps and tags.
|
||||
|
||||
Recipes are edited as plain text (one ingredient per line, group headers end with ":",
|
||||
steps separated by blank lines). This module converts between that text and the
|
||||
structured data stored in the database, and scales amounts for the serving calculator.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
|
||||
MAX_TAGS = 15
|
||||
MAX_TAG_LENGTH = 40
|
||||
|
||||
# Abbreviations that get a canonical spelling.
|
||||
CANONICAL_UNITS = {
|
||||
"g": "g", "gr": "g", "gr.": "g", "gramm": "g",
|
||||
"kg": "kg", "kilo": "kg", "kilogramm": "kg",
|
||||
"mg": "mg",
|
||||
"ml": "ml", "milliliter": "ml",
|
||||
"cl": "cl", "dl": "dl",
|
||||
"l": "l", "liter": "l", "ltr": "l", "ltr.": "l",
|
||||
"el": "EL", "el.": "EL", "essl.": "EL", "esslöffel": "EL", "tbsp": "EL", "tbsp.": "EL",
|
||||
"tl": "TL", "tl.": "TL", "teel.": "TL", "teelöffel": "TL", "tsp": "TL", "tsp.": "TL",
|
||||
"msp": "Msp.", "msp.": "Msp.", "messerspitze": "Msp.", "messerspitzen": "Msp.",
|
||||
"pck": "Pck.", "pck.": "Pck.", "pkg": "Pck.", "pkg.": "Pck.", "päckchen": "Pck.", "packung": "Pck.",
|
||||
"stk": "Stück", "stk.": "Stück", "st.": "Stück", "stück": "Stück",
|
||||
"prise": "Prise", "prisen": "Prise",
|
||||
"cup": "Cup", "cups": "Cup", "oz": "oz", "lb": "lb", "lbs": "lb",
|
||||
}
|
||||
# Unit words kept as written (plural forms matter in German).
|
||||
UNIT_WORDS = {
|
||||
"bund", "dose", "dosen", "zehe", "zehen", "tasse", "tassen", "becher", "glas", "gläser",
|
||||
"scheibe", "scheiben", "handvoll", "zweig", "zweige", "blatt", "blätter", "schuss",
|
||||
"tropfen", "würfel", "stange", "stangen", "knolle", "knollen", "kopf", "köpfe",
|
||||
"flasche", "flaschen", "beutel", "tafel", "tafeln", "spritzer", "stiel", "stiele",
|
||||
}
|
||||
|
||||
FRACTION_CHARS = {"½": Decimal("0.5"), "¼": Decimal("0.25"), "¾": Decimal("0.75"), "⅓": Decimal(1) / 3,
|
||||
"⅔": Decimal(2) / 3, "⅛": Decimal("0.125")}
|
||||
NICE_FRACTIONS = [(Decimal("0.125"), "⅛"), (Decimal("0.25"), "¼"), (Decimal(1) / 3, "⅓"), (Decimal("0.5"), "½"),
|
||||
(Decimal(2) / 3, "⅔"), (Decimal("0.75"), "¾")]
|
||||
|
||||
_FRAC = "".join(FRACTION_CHARS)
|
||||
_NUMBER = rf"(?:\d+\s+\d+\s*/\s*\d+|\d+\s*/\s*\d+|\d+\s*[{_FRAC}]|[{_FRAC}]|\d+(?:[.,]\d+)?)"
|
||||
_AMOUNT_RE = re.compile(rf"^(?P<a>{_NUMBER})(?:\s*(?:-|–|bis)\s*(?P<b>{_NUMBER}))?(?=[\s\D]|$)", re.IGNORECASE)
|
||||
_BULLET_RE = re.compile(r"^\s*(?:[-*•·▢□◦]|\d+[.)](?=\s))\s*")
|
||||
_STEP_NUMBER_RE = re.compile(r"^\s*(?:schritt\s*)?\d+\s*[.):]\s*", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedIngredient:
|
||||
name: str
|
||||
amount: Decimal | None = None
|
||||
amount_max: Decimal | None = None
|
||||
unit: str = ""
|
||||
note: str = ""
|
||||
group: str = ""
|
||||
|
||||
|
||||
def parse_number(text):
|
||||
text = text.strip().replace(" ", " ")
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
if "/" in text:
|
||||
parts = text.split()
|
||||
whole = Decimal(parts[0]) if len(parts) == 2 else Decimal(0)
|
||||
num, den = (parts[-1] if len(parts) == 2 else text).replace(" ", "").split("/")
|
||||
if Decimal(den) == 0:
|
||||
return None
|
||||
return whole + Decimal(num) / Decimal(den)
|
||||
if text[-1] in FRACTION_CHARS:
|
||||
whole = text[:-1].strip()
|
||||
return (Decimal(whole) if whole else Decimal(0)) + FRACTION_CHARS[text[-1]]
|
||||
return Decimal(text.replace(",", "."))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _split_unit(rest):
|
||||
"""Split a leading unit off the remaining text. Returns (unit, remainder)."""
|
||||
match = re.match(r"^(\S+)(?:\s+(.*))?$", rest)
|
||||
if not match:
|
||||
return "", rest
|
||||
word, remainder = match.group(1), (match.group(2) or "").strip()
|
||||
if not remainder:
|
||||
return "", rest # "2 Eier": the only word is the ingredient, not a unit
|
||||
key = word.lower()
|
||||
if key in CANONICAL_UNITS:
|
||||
return CANONICAL_UNITS[key], remainder
|
||||
if key in UNIT_WORDS:
|
||||
return word, remainder
|
||||
return "", rest
|
||||
|
||||
|
||||
def parse_ingredient_line(line, group=""):
|
||||
line = _BULLET_RE.sub("", line.strip()).strip()
|
||||
if not line:
|
||||
return None
|
||||
amount = amount_max = None
|
||||
rest = line
|
||||
match = _AMOUNT_RE.match(line)
|
||||
if match:
|
||||
amount = parse_number(match.group("a"))
|
||||
amount_max = parse_number(match.group("b")) if match.group("b") else None
|
||||
if amount is not None:
|
||||
rest = line[match.end():].strip()
|
||||
# "200g Mehl": unit glued to the number
|
||||
glued = re.match(r"^([a-zA-ZäöüÄÖÜ.]+)\s+(.*)$", rest)
|
||||
if glued and glued.group(1).lower() in CANONICAL_UNITS and not line[match.end():match.end() + 1].isspace():
|
||||
unit, rest = CANONICAL_UNITS[glued.group(1).lower()], glued.group(2)
|
||||
else:
|
||||
unit, rest = _split_unit(rest)
|
||||
else:
|
||||
unit = ""
|
||||
else:
|
||||
unit = ""
|
||||
name, note = rest, ""
|
||||
if "," in rest:
|
||||
name, note = (part.strip() for part in rest.split(",", 1))
|
||||
if not name:
|
||||
name, note = note, ""
|
||||
return ParsedIngredient(name=name, amount=amount, amount_max=amount_max, unit=unit, note=note, group=group)
|
||||
|
||||
|
||||
def is_group_header(line):
|
||||
stripped = line.strip()
|
||||
return stripped.endswith(":") and len(stripped) > 1 and not _AMOUNT_RE.match(stripped)
|
||||
|
||||
|
||||
def parse_ingredients(text):
|
||||
"""Parse the ingredient textarea into a list of ParsedIngredient."""
|
||||
items, group = [], ""
|
||||
for raw in (text or "").splitlines():
|
||||
if not raw.strip():
|
||||
continue
|
||||
if is_group_header(raw):
|
||||
group = _BULLET_RE.sub("", raw.strip())[:-1].strip()[:100]
|
||||
continue
|
||||
item = parse_ingredient_line(raw, group)
|
||||
if item:
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def parse_steps(text):
|
||||
"""Blank lines separate steps; without blank lines every line is a step."""
|
||||
text = (text or "").replace("\r\n", "\n").strip()
|
||||
if not text:
|
||||
return []
|
||||
chunks = re.split(r"\n\s*\n", text) if re.search(r"\n\s*\n", text) else text.split("\n")
|
||||
steps = []
|
||||
for chunk in chunks:
|
||||
chunk = " ".join(part.strip() for part in chunk.split("\n") if part.strip())
|
||||
chunk = _STEP_NUMBER_RE.sub("", chunk).strip()
|
||||
if chunk:
|
||||
steps.append(chunk)
|
||||
return steps
|
||||
|
||||
|
||||
def normalize_tag(tag):
|
||||
tag = re.sub(r"\s+", " ", (tag or "").strip().lstrip("#")).lower()
|
||||
return tag[:MAX_TAG_LENGTH]
|
||||
|
||||
|
||||
def parse_tags(text):
|
||||
seen, tags = set(), []
|
||||
for raw in re.split(r"[,;\n]", text or ""):
|
||||
tag = normalize_tag(raw)
|
||||
if tag and tag not in seen:
|
||||
seen.add(tag)
|
||||
tags.append(tag)
|
||||
return tags[:MAX_TAGS]
|
||||
|
||||
|
||||
# --- Formatting & scaling ------------------------------------------------------------
|
||||
|
||||
|
||||
def format_amount(value):
|
||||
if value is None:
|
||||
return ""
|
||||
value = Decimal(value)
|
||||
whole = int(value)
|
||||
frac = value - whole
|
||||
for target, glyph in NICE_FRACTIONS:
|
||||
if abs(frac - target) < Decimal("0.02"):
|
||||
return f"{whole} {glyph}" if whole else glyph
|
||||
if frac < Decimal("0.02"):
|
||||
return str(whole)
|
||||
if frac > Decimal("0.98"):
|
||||
return str(whole + 1)
|
||||
rounded = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP).normalize()
|
||||
return f"{rounded:f}".replace(".", ",")
|
||||
|
||||
|
||||
def _round_metric(value):
|
||||
"""Gram/millilitre amounts: no fractions; coarser rounding for big numbers."""
|
||||
if value >= 100:
|
||||
step = Decimal(5)
|
||||
elif value >= 10:
|
||||
step = Decimal(1)
|
||||
else:
|
||||
return value
|
||||
return (value / step).quantize(Decimal(1), rounding=ROUND_HALF_UP) * step
|
||||
|
||||
|
||||
def scale_amount(amount, amount_max, unit, factor):
|
||||
"""Scale an amount (and optional upper bound of a range) and normalise metric units
|
||||
(1250 g -> 1,25 kg). Returns (amount, amount_max, unit)."""
|
||||
if amount is None:
|
||||
return None, None, unit
|
||||
values = [Decimal(v) * Decimal(factor) if v is not None else None for v in (amount, amount_max)]
|
||||
if unit in ("g", "ml"):
|
||||
values = [_round_metric(v) if v is not None else None for v in values]
|
||||
if values[0] >= 1000:
|
||||
values = [v / 1000 if v is not None else None for v in values]
|
||||
unit = "kg" if unit == "g" else "l"
|
||||
return values[0], values[1], unit
|
||||
|
||||
|
||||
def format_ingredient(amount, amount_max, unit, name, note=""):
|
||||
parts = []
|
||||
if amount is not None:
|
||||
parts.append(format_amount(amount) + (f"-{format_amount(amount_max)}" if amount_max is not None else ""))
|
||||
if unit:
|
||||
parts.append(unit)
|
||||
parts.append(name)
|
||||
text = " ".join(parts)
|
||||
return f"{text}, {note}" if note else text
|
||||
|
||||
|
||||
def ingredients_to_text(ingredients):
|
||||
"""Inverse of parse_ingredients for the edit form. Accepts objects with the same attributes."""
|
||||
lines, group = [], ""
|
||||
for ing in ingredients:
|
||||
if ing.group != group:
|
||||
if lines:
|
||||
lines.append("")
|
||||
if ing.group:
|
||||
lines.append(f"{ing.group}:")
|
||||
group = ing.group
|
||||
lines.append(format_ingredient(ing.amount, ing.amount_max, ing.unit, ing.name, ing.note))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def steps_to_text(steps):
|
||||
return "\n\n".join(steps)
|
||||
@@ -0,0 +1,18 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "recipes"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.recipe_list, name="list"),
|
||||
path("rezepte/neu/", views.recipe_create, name="create"),
|
||||
path("rezepte/<int:pk>/", views.recipe_detail, name="detail"),
|
||||
path("rezepte/<int:pk>/bearbeiten/", views.recipe_edit, name="edit"),
|
||||
path("rezepte/<int:pk>/loeschen/", views.recipe_delete, name="delete"),
|
||||
path("rezepte/<int:pk>/favorit/", views.favorite_toggle, name="favorite"),
|
||||
path("rezepte/<int:pk>/fotos/", views.photo_upload, name="photo_upload"),
|
||||
path("rezepte/<int:pk>/fotos/<int:image_id>/titelbild/", views.photo_cover, name="photo_cover"),
|
||||
path("rezepte/<int:pk>/fotos/<int:image_id>/loeschen/", views.photo_delete, name="photo_delete"),
|
||||
path("bilder/<uuid:image_uuid>/<str:size>.webp", views.photo_serve, name="photo"),
|
||||
]
|
||||
@@ -0,0 +1,190 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.paginator import Paginator
|
||||
from django.db import transaction
|
||||
from django.db.models import Exists, OuterRef, Prefetch
|
||||
from django.http import FileResponse, Http404
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from . import images
|
||||
from .forms import PhotoUploadForm, RecipeForm, SearchForm
|
||||
from .models import Favorite, Recipe, RecipeImage, Tag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PAGE_SIZE = 24
|
||||
|
||||
|
||||
def _visible(request):
|
||||
return Recipe.objects.visible_to(request.user)
|
||||
|
||||
|
||||
def attach_photos(request, recipe, files):
|
||||
"""Process uploaded files and attach them. Problems are reported as messages, not exceptions."""
|
||||
has_cover = recipe.images.filter(is_cover=True).exists()
|
||||
position = recipe.images.count()
|
||||
for f in files:
|
||||
try:
|
||||
stored = images.process_upload(f)
|
||||
except images.ImageError as error:
|
||||
messages.warning(request, f"{f.name}: {error}")
|
||||
continue
|
||||
RecipeImage.objects.create(recipe=recipe, uploaded_by=request.user, position=position,
|
||||
is_cover=not has_cover, **stored)
|
||||
has_cover = True
|
||||
position += 1
|
||||
|
||||
|
||||
@require_GET
|
||||
def recipe_list(request):
|
||||
form = SearchForm(request.GET or None)
|
||||
form.is_valid()
|
||||
data = getattr(form, "cleaned_data", {}) or {}
|
||||
qs = _visible(request).search(data.get("q", ""), form.ingredient_terms() if data else [], [data["tag"]] if data.get("tag") else [])
|
||||
view = data.get("ansicht", "")
|
||||
if view == "meine":
|
||||
qs = qs.filter(owner=request.user)
|
||||
elif view == "favoriten":
|
||||
qs = qs.filter(favorited_by__user=request.user)
|
||||
qs = qs.select_related("owner").prefetch_related(
|
||||
Prefetch("images", queryset=RecipeImage.objects.order_by("-is_cover", "position"))
|
||||
).annotate(is_favorite=Exists(Favorite.objects.filter(user=request.user, recipe=OuterRef("pk"))))
|
||||
page = Paginator(qs, PAGE_SIZE).get_page(request.GET.get("seite"))
|
||||
tags = Tag.visible_to(request.user)
|
||||
return render(request, "recipes/list.html", {
|
||||
"form": form, "page": page, "tags": tags, "active_tag": data.get("tag", ""),
|
||||
"filtered": bool(data.get("q") or data.get("zutat") or data.get("tag") or view),
|
||||
})
|
||||
|
||||
|
||||
@require_GET
|
||||
def recipe_detail(request, pk):
|
||||
recipe = get_object_or_404(_visible(request).select_related("owner"), pk=pk)
|
||||
try:
|
||||
servings = int(request.GET.get("portionen", recipe.servings))
|
||||
except ValueError:
|
||||
servings = recipe.servings
|
||||
servings = max(1, min(servings, 200))
|
||||
return render(request, "recipes/detail.html", {
|
||||
"recipe": recipe,
|
||||
"servings": servings,
|
||||
"ingredient_groups": recipe.scaled_ingredients(servings),
|
||||
"steps": recipe.steps.all(),
|
||||
"images": recipe.images.all(),
|
||||
"tags": recipe.tags.all(),
|
||||
"is_owner": recipe.owner_id == request.user.id,
|
||||
"can_delete": Recipe.objects.deletable_by(request.user).filter(pk=recipe.pk).exists(),
|
||||
"is_favorite": Favorite.objects.filter(user=request.user, recipe=recipe).exists(),
|
||||
})
|
||||
|
||||
|
||||
def recipe_create(request):
|
||||
form = RecipeForm(request.POST or None, request.FILES or None)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
recipe = form.save(owner=request.user)
|
||||
attach_photos(request, recipe, form.cleaned_data.get("photos") or [])
|
||||
messages.success(request, "Rezept gespeichert.")
|
||||
return redirect("recipes:detail", pk=recipe.pk)
|
||||
return render(request, "recipes/form.html", {"form": form, "is_new": True})
|
||||
|
||||
|
||||
def recipe_edit(request, pk):
|
||||
recipe = get_object_or_404(Recipe.objects.editable_by(request.user), pk=pk)
|
||||
form = RecipeForm(request.POST or None, request.FILES or None, instance=recipe)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
recipe = form.save()
|
||||
attach_photos(request, recipe, form.cleaned_data.get("photos") or [])
|
||||
messages.success(request, "Änderungen gespeichert.")
|
||||
return redirect("recipes:detail", pk=recipe.pk)
|
||||
return render(request, "recipes/form.html", {"form": form, "recipe": recipe, "images": recipe.images.all()})
|
||||
|
||||
|
||||
def recipe_delete(request, pk):
|
||||
recipe = get_object_or_404(Recipe.objects.deletable_by(request.user), pk=pk)
|
||||
if request.method == "POST":
|
||||
delete_recipe(recipe)
|
||||
messages.success(request, f"„{recipe.title}“ wurde gelöscht.")
|
||||
return redirect("recipes:list")
|
||||
return render(request, "recipes/delete.html", {"recipe": recipe})
|
||||
|
||||
|
||||
def delete_recipe(recipe):
|
||||
paths = [p for img in recipe.images.all() for p in (img.file, img.thumb)]
|
||||
with transaction.atomic():
|
||||
recipe.delete()
|
||||
images.delete_files(*paths)
|
||||
|
||||
|
||||
@require_POST
|
||||
def favorite_toggle(request, pk):
|
||||
recipe = get_object_or_404(_visible(request), pk=pk)
|
||||
favorite, created = Favorite.objects.get_or_create(user=request.user, recipe=recipe)
|
||||
if not created:
|
||||
favorite.delete()
|
||||
target = request.POST.get("next", "")
|
||||
if url_has_allowed_host_and_scheme(target, allowed_hosts={request.get_host()}) and target.startswith("/"):
|
||||
return redirect(target)
|
||||
return redirect("recipes:detail", pk=recipe.pk)
|
||||
|
||||
|
||||
@require_POST
|
||||
def photo_upload(request, pk):
|
||||
recipe = get_object_or_404(Recipe.objects.editable_by(request.user), pk=pk)
|
||||
form = PhotoUploadForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
files = form.cleaned_data["photos"]
|
||||
if recipe.images.count() + len(files) > settings.IMAGE_MAX_PER_RECIPE:
|
||||
messages.error(request, f"Ein Rezept kann höchstens {settings.IMAGE_MAX_PER_RECIPE} Bilder haben.")
|
||||
else:
|
||||
attach_photos(request, recipe, files)
|
||||
else:
|
||||
for error in form.errors.get("photos", []):
|
||||
messages.error(request, error)
|
||||
return redirect("recipes:edit", pk=recipe.pk)
|
||||
|
||||
|
||||
@require_POST
|
||||
def photo_cover(request, pk, image_id):
|
||||
recipe = get_object_or_404(Recipe.objects.editable_by(request.user), pk=pk)
|
||||
image = get_object_or_404(recipe.images, pk=image_id)
|
||||
with transaction.atomic():
|
||||
recipe.images.update(is_cover=False)
|
||||
image.is_cover = True
|
||||
image.save(update_fields=["is_cover"])
|
||||
return redirect("recipes:edit", pk=recipe.pk)
|
||||
|
||||
|
||||
@require_POST
|
||||
def photo_delete(request, pk, image_id):
|
||||
recipe = get_object_or_404(Recipe.objects.editable_by(request.user), pk=pk)
|
||||
image = get_object_or_404(recipe.images, pk=image_id)
|
||||
was_cover = image.is_cover
|
||||
image.delete()
|
||||
images.delete_files(image.file, image.thumb)
|
||||
if was_cover:
|
||||
first = recipe.images.order_by("position", "created_at").first()
|
||||
if first:
|
||||
first.is_cover = True
|
||||
first.save(update_fields=["is_cover"])
|
||||
return redirect("recipes:edit", pk=recipe.pk)
|
||||
|
||||
|
||||
@require_GET
|
||||
def photo_serve(request, image_uuid, size):
|
||||
"""Serve a photo only if the user may see its recipe. Unknown and forbidden look the same (404)."""
|
||||
image = get_object_or_404(RecipeImage.objects.filter(recipe__in=_visible(request)), uuid=image_uuid)
|
||||
relative = image.thumb if size == "klein" else image.file
|
||||
try:
|
||||
path = images.file_path(relative)
|
||||
handle = path.open("rb")
|
||||
except (images.ImageError, OSError):
|
||||
logger.warning("Missing image file %s", relative)
|
||||
raise Http404 from None
|
||||
response = FileResponse(handle, content_type="image/webp")
|
||||
# Short browser-only caching, so a recipe made private stops showing up quickly.
|
||||
response["Cache-Control"] = "private, max-age=300"
|
||||
return response
|
||||
@@ -3,3 +3,8 @@ psycopg[binary]==3.3.6
|
||||
argon2-cffi==25.1.0
|
||||
gunicorn==26.2.0
|
||||
whitenoise==6.12.0
|
||||
Pillow==13.0.0
|
||||
pillow-heif==1.8.1
|
||||
openai==3.41.0
|
||||
pydantic==2.14.2
|
||||
urllib3==2.8.0
|
||||
|
||||
@@ -183,3 +183,73 @@ input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); ou
|
||||
|
||||
.copyrow { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
|
||||
.copyrow input { flex: 1; min-width: 0; font-family: ui-monospace, monospace; font-size: 0.9rem; }
|
||||
|
||||
/* --- Recipes ----------------------------------------------------------------- */
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||
.search { margin-bottom: 1rem; }
|
||||
.search-row { display: flex; gap: 0.5rem; }
|
||||
.search-row input { flex: 1; min-width: 0; }
|
||||
input[type="search"] {
|
||||
width: 100%; min-height: var(--tap); padding: 0.6rem 0.75rem; font: inherit; color: var(--text);
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 999px;
|
||||
}
|
||||
.filters { margin-top: 0.5rem; }
|
||||
.filters summary { cursor: pointer; color: var(--accent); min-height: 36px; display: flex; align-items: center; }
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0 0 1rem; }
|
||||
.chip {
|
||||
display: inline-block; padding: 0.3rem 0.75rem; border-radius: 999px; font-size: 0.9rem;
|
||||
background: var(--surface); border: 1px solid var(--border); color: var(--text); text-decoration: none;
|
||||
}
|
||||
.chip.active { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
|
||||
|
||||
.recipe-grid { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.75rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@media (min-width: 640px) { .recipe-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
.recipe-card {
|
||||
display: block; height: 100%; overflow: hidden; text-decoration: none; color: var(--text);
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
}
|
||||
.recipe-card img, .recipe-card .placeholder { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
|
||||
.recipe-card .placeholder { display: grid; place-items: center; font-size: 2.5rem; background: var(--border); }
|
||||
.recipe-card-body { padding: 0.6rem 0.75rem 0.75rem; display: flex; flex-direction: column; gap: 0.2rem; line-height: 1.3; }
|
||||
.recipe-card-body .muted { font-size: 0.82rem; }
|
||||
.empty { text-align: center; }
|
||||
.pager { display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; gap: 0.5rem; }
|
||||
|
||||
.gallery { display: flex; gap: 0.5rem; overflow-x: auto; scroll-snap-type: x mandatory; margin: 0 -1rem 1rem; padding: 0 1rem; }
|
||||
.gallery a { flex: 0 0 100%; scroll-snap-align: center; }
|
||||
.gallery img { width: 100%; height: auto; max-height: 60vh; object-fit: cover; border-radius: var(--radius); display: block; }
|
||||
.gallery:has(a:nth-child(2)) a { flex-basis: 88%; }
|
||||
|
||||
.recipe-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.5rem 0 1rem; }
|
||||
.recipe-actions form { margin: 0; }
|
||||
.lead { font-size: 1.05rem; }
|
||||
.facts { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; margin: 0 0 1rem; }
|
||||
.facts .muted { display: block; font-size: 0.8rem; }
|
||||
.section-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 0.5rem; }
|
||||
.section-head h2 { margin: 0; }
|
||||
h3 { font-size: 1rem; margin: 1rem 0 0.25rem; color: var(--muted); }
|
||||
.ingredients { list-style: none; padding: 0; margin: 0.5rem 0 0; }
|
||||
.ingredients li { display: flex; gap: 0.75rem; padding: 0.45rem 0; border-bottom: 1px solid var(--border); }
|
||||
.ingredients li:last-child { border-bottom: 0; }
|
||||
.ingredients .amount { flex: 0 0 6.5rem; text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.steps { padding-left: 1.4rem; margin: 0.5rem 0 0; }
|
||||
.steps li { margin-bottom: 0.9rem; padding-left: 0.25rem; }
|
||||
.notes { border-left: 4px solid var(--accent); }
|
||||
|
||||
.servings { display: flex; align-items: center; gap: 0.4rem; margin: 0; }
|
||||
.servings label { display: flex; align-items: center; gap: 0.4rem; font-weight: 400; margin: 0; }
|
||||
.servings input { width: 4rem; min-height: 40px; text-align: center; padding: 0.3rem; }
|
||||
.step-btn {
|
||||
display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%;
|
||||
border: 1px solid var(--accent); color: var(--accent); text-decoration: none; font-size: 1.3rem; font-weight: 600;
|
||||
}
|
||||
.step-btn.disabled { opacity: 0.35; }
|
||||
.small-link { font-size: 0.85rem; }
|
||||
|
||||
.recipe-form textarea { font-size: 16px; line-height: 1.45; }
|
||||
.recipe-form ul { list-style: none; padding: 0; margin: 0; }
|
||||
.recipe-form ul label { display: flex; align-items: center; gap: 0.5rem; font-weight: 400; min-height: var(--tap); }
|
||||
.form-buttons { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.photo-admin { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.75rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.photo-admin img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; display: block; margin-bottom: 0.4rem; }
|
||||
|
||||
@@ -12,3 +12,9 @@ document.addEventListener("click", async (event) => {
|
||||
button.textContent = "Bitte manuell kopieren";
|
||||
}
|
||||
});
|
||||
|
||||
// Serving calculator: recalculate as soon as the number changes.
|
||||
document.addEventListener("change", (event) => {
|
||||
const input = event.target.closest(".servings input[name=portionen]");
|
||||
if (input && input.value) input.form.requestSubmit();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Rezepte löschen{% endblock %}
|
||||
{% block content %}
|
||||
<div class="narrow">
|
||||
<h1>Rezepte von {{ target }} löschen?</h1>
|
||||
{% if count %}
|
||||
<p>{{ count }} Rezept{{ count|pluralize:"e" }} (auch private) werden mit allen Fotos, Kommentaren und Favoriten endgültig gelöscht. Das Konto bleibt deaktiviert bestehen.</p>
|
||||
<form method="post" class="form-buttons">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="button danger">Endgültig löschen</button>
|
||||
<a class="button secondary" href="{% url 'accounts:admin_users' %}">Abbrechen</a>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>{{ target }} hat keine Rezepte.</p>
|
||||
<p><a href="{% url 'accounts:admin_users' %}">Zurück</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -29,6 +29,7 @@
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<form method="post" action="{% url 'accounts:admin_user_action' u.pk 'activate' %}">{% csrf_token %}<button class="button secondary">Aktivieren</button></form>
|
||||
<a class="button danger" href="{% url 'accounts:admin_user_delete_recipes' u.pk %}">Rezepte löschen …</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
+2
-2
@@ -11,12 +11,12 @@
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="{% url 'core:home' %}">Kochbuch</a>
|
||||
<a class="brand" href="{% url 'recipes:list' %}">Kochbuch</a>
|
||||
{% if user.is_authenticated %}
|
||||
<details class="menu">
|
||||
<summary aria-label="Menü">☰</summary>
|
||||
<nav>
|
||||
<a href="{% url 'core:home' %}">Rezepte</a>
|
||||
<a href="{% url 'recipes:list' %}">Rezepte</a>
|
||||
<a href="{% url 'accounts:profile' %}">Mein Konto</a>
|
||||
{% if user.is_admin %}
|
||||
<a href="{% url 'accounts:admin_users' %}">Nutzer verwalten</a>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Rezepte{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Hallo {{ user.display_name }}!</h1>
|
||||
<div class="card">
|
||||
<p>Hier entstehen bald die Rezepte. Konto, Einladungen und Rollen sind bereits eingerichtet.</p>
|
||||
{% if user.is_admin %}
|
||||
<p><a class="button" href="{% url 'accounts:admin_invitations' %}">Jemanden einladen</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -3,5 +3,5 @@
|
||||
{% block content %}
|
||||
<h1>Kein Zugriff</h1>
|
||||
<p>Dafür fehlen dir die Berechtigungen.</p>
|
||||
<p><a href="{% url 'core:home' %}">Zur Startseite</a></p>
|
||||
<p><a href="{% url 'recipes:list' %}">Zur Startseite</a></p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
{% block content %}
|
||||
<h1>Nicht gefunden</h1>
|
||||
<p>Diese Seite gibt es nicht oder du hast keinen Zugriff darauf.</p>
|
||||
<p><a href="{% url 'core:home' %}">Zur Startseite</a></p>
|
||||
<p><a href="{% url 'recipes:list' %}">Zur Startseite</a></p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<form method="get" class="servings" aria-label="Portionen umrechnen">
|
||||
{% if servings > 1 %}<a class="step-btn" href="?portionen={{ servings|add:'-1' }}" aria-label="Weniger">−</a>{% else %}<span class="step-btn disabled">−</span>{% endif %}
|
||||
<label><input type="number" name="portionen" value="{{ servings }}" min="1" max="200" inputmode="numeric"> {{ recipe.servings_label|default:"Portionen" }}</label>
|
||||
<a class="step-btn" href="?portionen={{ servings|add:'1' }}" aria-label="Mehr">+</a>
|
||||
{% if servings != recipe.servings %}<a class="small-link" href="?">Original ({{ recipe.servings }})</a>{% endif %}
|
||||
</form>
|
||||
@@ -0,0 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Rezept löschen{% endblock %}
|
||||
{% block content %}
|
||||
<div class="narrow">
|
||||
<h1>Rezept löschen?</h1>
|
||||
<p>„{{ recipe.title }}“ wird mit allen Fotos{% if recipe.comments %}, Kommentaren{% endif %} und Favoriten endgültig gelöscht.</p>
|
||||
<form method="post" class="form-buttons">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="button danger">Endgültig löschen</button>
|
||||
<a class="button secondary" href="{% url 'recipes:detail' recipe.pk %}">Abbrechen</a>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ recipe.title }}{% endblock %}
|
||||
{% block content %}
|
||||
<article class="recipe">
|
||||
{% if images %}
|
||||
<div class="gallery">
|
||||
{% for img in images %}
|
||||
<a href="{% url 'recipes:photo' img.uuid 'gross' %}"><img src="{% url 'recipes:photo' img.uuid 'gross' %}" alt="Foto {{ forloop.counter }}" width="{{ img.width }}" height="{{ img.height }}"{% if not forloop.first %} loading="lazy"{% endif %}></a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h1>{{ recipe.title }}</h1>
|
||||
<p class="muted">
|
||||
von {{ recipe.owner.display_name }} · {% if recipe.is_public %}öffentlich{% else %}🔒 privat{% endif %}
|
||||
· geändert {{ recipe.updated_at|date:"d.m.Y" }}
|
||||
</p>
|
||||
|
||||
<div class="recipe-actions">
|
||||
<form method="post" action="{% url 'recipes:favorite' recipe.pk %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||
<button type="submit" class="button secondary">{% if is_favorite %}★ Favorit{% else %}☆ Merken{% endif %}</button>
|
||||
</form>
|
||||
{% block extra_actions %}{% endblock %}
|
||||
{% if is_owner %}<a class="button secondary" href="{% url 'recipes:edit' recipe.pk %}">Bearbeiten</a>{% endif %}
|
||||
{% if can_delete %}<a class="button danger" href="{% url 'recipes:delete' recipe.pk %}">Löschen</a>{% endif %}
|
||||
</div>
|
||||
|
||||
{% if recipe.description %}<p class="lead">{{ recipe.description|linebreaksbr }}</p>{% endif %}
|
||||
|
||||
{% if recipe.prep_minutes or recipe.cook_minutes %}
|
||||
<ul class="facts">
|
||||
{% if recipe.prep_minutes %}<li><span class="muted">Zubereitung</span> {{ recipe.prep_minutes }} Min.</li>{% endif %}
|
||||
{% if recipe.cook_minutes %}<li><span class="muted">Koch-/Backzeit</span> {{ recipe.cook_minutes }} Min.</li>{% endif %}
|
||||
{% if recipe.prep_minutes and recipe.cook_minutes %}<li><span class="muted">Gesamt</span> {{ recipe.total_minutes }} Min.</li>{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if tags %}
|
||||
<p class="chips">{% for tag in tags %}<a class="chip" href="{% url 'recipes:list' %}?tag={{ tag.name|urlencode }}">#{{ tag.name }}</a>{% endfor %}</p>
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<div class="section-head">
|
||||
<h2>Zutaten</h2>
|
||||
{% include "recipes/_servings.html" %}
|
||||
</div>
|
||||
{% for group, items in ingredient_groups %}
|
||||
{% if group %}<h3>{{ group }}</h3>{% endif %}
|
||||
<ul class="ingredients">
|
||||
{% for ing in items %}
|
||||
<li><span class="amount">{{ ing.amount }} {{ ing.unit }}</span> <span>{{ ing.name }}{% if ing.note %}<span class="muted">, {{ ing.note }}</span>{% endif %}</span></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% empty %}
|
||||
<p class="muted">Keine Zutaten angegeben.</p>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Zubereitung</h2>
|
||||
{% if steps %}
|
||||
<ol class="steps">
|
||||
{% for step in steps %}<li>{{ step.text|linebreaksbr }}</li>{% endfor %}
|
||||
</ol>
|
||||
{% else %}
|
||||
<p class="muted">Keine Schritte angegeben.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if recipe.author_notes %}
|
||||
<section class="card notes">
|
||||
<h2>Notizen von {{ recipe.owner.display_name }}</h2>
|
||||
<p>{{ recipe.author_notes|linebreaksbr }}</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if recipe.source_url %}
|
||||
<p class="muted">Quelle: <a href="{{ recipe.source_url }}" rel="noopener noreferrer nofollow" target="_blank">{{ recipe.source_url|truncatechars:60 }}</a></p>
|
||||
{% endif %}
|
||||
|
||||
{% block after_recipe %}{% endblock %}
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{% if is_new %}Neues Rezept{% else %}{{ recipe.title }} bearbeiten{% endif %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% if is_new %}Neues Rezept{% else %}Rezept bearbeiten{% endif %}</h1>
|
||||
|
||||
{% block before_form %}{% endblock %}
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="card recipe-form" novalidate>
|
||||
{% csrf_token %}
|
||||
{% include "form_fields.html" %}
|
||||
{% block form_buttons %}
|
||||
<div class="form-buttons">
|
||||
<button type="submit" class="button">Speichern</button>
|
||||
<a class="button secondary" href="{% if recipe %}{% url 'recipes:detail' recipe.pk %}{% else %}{% url 'recipes:list' %}{% endif %}">Abbrechen</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</form>
|
||||
|
||||
{% if recipe and images %}
|
||||
<section class="card">
|
||||
<h2>Fotos</h2>
|
||||
<ul class="photo-admin">
|
||||
{% for img in images %}
|
||||
<li>
|
||||
<img src="{% url 'recipes:photo' img.uuid 'klein' %}" alt="Foto {{ forloop.counter }}" loading="lazy">
|
||||
<div class="action-buttons">
|
||||
{% if img.is_cover %}
|
||||
<span class="badge">Titelbild</span>
|
||||
{% else %}
|
||||
<form method="post" action="{% url 'recipes:photo_cover' recipe.pk img.pk %}">{% csrf_token %}<button class="button secondary small">Als Titelbild</button></form>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'recipes:photo_delete' recipe.pk img.pk %}">{% csrf_token %}<button class="button danger small">Entfernen</button></form>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Rezepte{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>Rezepte</h1>
|
||||
<a class="button" href="{% url 'recipes:create' %}">+ Neu</a>
|
||||
</div>
|
||||
|
||||
<form method="get" class="search" role="search">
|
||||
<div class="search-row">
|
||||
{{ form.q }}
|
||||
<button type="submit" class="button">Suchen</button>
|
||||
</div>
|
||||
<details class="filters"{% if form.zutat.value or form.ansicht.value %} open{% endif %}>
|
||||
<summary>Filter</summary>
|
||||
<div class="field"><label for="{{ form.zutat.id_for_label }}">{{ form.zutat.label }}</label>{{ form.zutat }}</div>
|
||||
<div class="field"><label for="{{ form.ansicht.id_for_label }}">{{ form.ansicht.label }}</label>{{ form.ansicht }}</div>
|
||||
</details>
|
||||
{% if active_tag %}<input type="hidden" name="tag" value="{{ active_tag }}">{% endif %}
|
||||
</form>
|
||||
|
||||
{% if tags %}
|
||||
<nav class="chips" aria-label="Tags">
|
||||
{% for tag in tags %}
|
||||
{% if tag.name == active_tag %}
|
||||
<a class="chip active" href="{% querystring tag=None seite=None %}">#{{ tag.name }} ✕</a>
|
||||
{% else %}
|
||||
<a class="chip" href="{% querystring tag=tag.name seite=None %}">#{{ tag.name }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% if page.object_list %}
|
||||
<ul class="recipe-grid">
|
||||
{% for recipe in page.object_list %}
|
||||
<li>
|
||||
<a class="recipe-card" href="{% url 'recipes:detail' recipe.pk %}">
|
||||
{% with cover=recipe.cover %}
|
||||
{% if cover %}
|
||||
<img src="{% url 'recipes:photo' cover.uuid 'klein' %}" alt="" loading="lazy" width="480" height="360">
|
||||
{% else %}
|
||||
<div class="placeholder" aria-hidden="true">🍲</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<div class="recipe-card-body">
|
||||
<strong>{{ recipe.title }}</strong>
|
||||
<span class="muted">
|
||||
{% if recipe.total_minutes %}{{ recipe.total_minutes }} Min. · {% endif %}{{ recipe.owner.display_name }}
|
||||
{% if not recipe.is_public %} · 🔒{% endif %}{% if recipe.is_favorite %} · ★{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if page.has_other_pages %}
|
||||
<nav class="pager">
|
||||
{% if page.has_previous %}<a class="button secondary" href="{% querystring seite=page.previous_page_number %}">← Zurück</a>{% endif %}
|
||||
<span class="muted">Seite {{ page.number }} von {{ page.paginator.num_pages }}</span>
|
||||
{% if page.has_next %}<a class="button secondary" href="{% querystring seite=page.next_page_number %}">Weiter →</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% elif filtered %}
|
||||
<p class="muted">Keine Rezepte gefunden. <a href="{% url 'recipes:list' %}">Filter zurücksetzen</a></p>
|
||||
{% else %}
|
||||
<div class="card empty">
|
||||
<p>Noch keine Rezepte. Leg das erste an!</p>
|
||||
<a class="button" href="{% url 'recipes:create' %}">Rezept anlegen</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -35,3 +35,9 @@ def admin_client(admin_user):
|
||||
@pytest.fixture
|
||||
def user_client(user):
|
||||
return logged_in_client(user)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def media_root(tmp_path, settings):
|
||||
settings.MEDIA_ROOT = tmp_path / "media"
|
||||
return settings.MEDIA_ROOT
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import io
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from PIL import Image
|
||||
|
||||
from recipes import parsing
|
||||
from recipes.models import Recipe
|
||||
|
||||
|
||||
def make_recipe(owner, title="Testrezept", visibility=Recipe.Visibility.PUBLIC, ingredients="200 g Mehl\n2 Eier",
|
||||
steps="Mischen.\n\nBacken.", tags=("kuchen",)):
|
||||
recipe = Recipe.objects.create(owner=owner, title=title, visibility=visibility, servings=4)
|
||||
recipe.set_ingredients(parsing.parse_ingredients(ingredients))
|
||||
recipe.set_steps(parsing.parse_steps(steps))
|
||||
recipe.set_tags(tags)
|
||||
return recipe
|
||||
|
||||
|
||||
def image_upload(name="foto.jpg", size=(1200, 900), fmt="JPEG", color=(200, 120, 40)):
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGB", size, color).save(buffer, fmt)
|
||||
content_type = {"JPEG": "image/jpeg", "PNG": "image/png", "WEBP": "image/webp"}[fmt]
|
||||
return SimpleUploadedFile(name, buffer.getvalue(), content_type=content_type)
|
||||
|
||||
|
||||
def form_data(**overrides):
|
||||
data = {
|
||||
"title": "Pfannkuchen",
|
||||
"description": "Einfach und schnell.",
|
||||
"servings": 4,
|
||||
"servings_label": "Portionen",
|
||||
"prep_minutes": 10,
|
||||
"cook_minutes": 15,
|
||||
"ingredients_text": "250 g Mehl\n500 ml Milch\n3 Eier\n1 Prise Salz",
|
||||
"steps_text": "Alles verrühren.\n\nIn der Pfanne ausbacken.",
|
||||
"tags_text": "Frühstück, Süß",
|
||||
"author_notes": "Mit Apfelmus servieren.",
|
||||
"source_url": "",
|
||||
"visibility": "private",
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
@@ -52,7 +52,7 @@ def test_deactivated_user_session_ends(admin_client, user):
|
||||
session = Client()
|
||||
session.force_login(user)
|
||||
admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "deactivate"]))
|
||||
assert session.get(reverse("core:home")).status_code == 302
|
||||
assert session.get(reverse("recipes:list")).status_code == 302
|
||||
|
||||
|
||||
def test_admin_cannot_demote_or_deactivate_self(admin_client, admin_user):
|
||||
|
||||
+7
-7
@@ -30,7 +30,7 @@ def test_login_with_valid_credentials(user):
|
||||
client = Client()
|
||||
response = login(client, "[email protected]", PASSWORD) # e-mail is case-insensitive
|
||||
assert response.status_code == 302
|
||||
assert client.get(reverse("core:home")).status_code == 200
|
||||
assert client.get(reverse("recipes:list")).status_code == 200
|
||||
|
||||
|
||||
def test_login_wrong_password_gives_generic_error(user):
|
||||
@@ -89,13 +89,13 @@ def test_login_redirect_rejects_external_next(user):
|
||||
{"email": user.email, "password": PASSWORD, "next": "https://evil.example/"},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert response["Location"] == reverse("core:home")
|
||||
assert response["Location"] == reverse("recipes:list")
|
||||
|
||||
|
||||
def test_logout_requires_post(user_client):
|
||||
assert user_client.get(reverse("accounts:logout")).status_code == 405
|
||||
assert user_client.post(reverse("accounts:logout")).status_code == 302
|
||||
assert user_client.get(reverse("core:home")).status_code == 302
|
||||
assert user_client.get(reverse("recipes:list")).status_code == 302
|
||||
|
||||
|
||||
def test_login_requires_csrf_token(user):
|
||||
@@ -145,7 +145,7 @@ def test_register_with_valid_invitation(admin_user):
|
||||
assert new_user.role == User.Role.USER
|
||||
invitation.refresh_from_db()
|
||||
assert invitation.used_by == new_user and invitation.status == "used"
|
||||
assert client.get(reverse("core:home")).status_code == 200
|
||||
assert client.get(reverse("recipes:list")).status_code == 200
|
||||
|
||||
|
||||
def test_invitation_is_single_use(admin_user):
|
||||
@@ -243,7 +243,7 @@ def test_reset_link_ends_existing_sessions(admin_user, user):
|
||||
reverse("accounts:password_reset", args=[token_from(link)]),
|
||||
{"password1": NEW_PASSWORD, "password2": NEW_PASSWORD},
|
||||
)
|
||||
assert old_session.get(reverse("core:home")).status_code == 302
|
||||
assert old_session.get(reverse("recipes:list")).status_code == 302
|
||||
|
||||
|
||||
def test_temp_password_forces_change(admin_client, user):
|
||||
@@ -252,9 +252,9 @@ def test_temp_password_forces_change(admin_client, user):
|
||||
client = Client()
|
||||
assert login(client, user.email, temp).status_code == 302
|
||||
# Every page redirects to the password change until a new password is set.
|
||||
assert client.get(reverse("core:home"))["Location"] == reverse("accounts:password_change")
|
||||
assert client.get(reverse("recipes:list"))["Location"] == reverse("accounts:password_change")
|
||||
client.post(reverse("accounts:password_change"), {"password1": NEW_PASSWORD, "password2": NEW_PASSWORD})
|
||||
assert client.get(reverse("core:home")).status_code == 200
|
||||
assert client.get(reverse("recipes:list")).status_code == 200
|
||||
user.refresh_from_db()
|
||||
assert not user.must_change_password and user.check_password(NEW_PASSWORD)
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from recipes.parsing import (
|
||||
format_amount,
|
||||
ingredients_to_text,
|
||||
parse_ingredient_line,
|
||||
parse_ingredients,
|
||||
parse_steps,
|
||||
parse_tags,
|
||||
scale_amount,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"line, amount, amount_max, unit, name, note",
|
||||
[
|
||||
("200 g Mehl, gesiebt", "200", None, "g", "Mehl", "gesiebt"),
|
||||
("200g Mehl", "200", None, "g", "Mehl", ""),
|
||||
("1,5l Milch", "1.5", None, "l", "Milch", ""),
|
||||
("1 ½ TL Salz", "1.5", None, "TL", "Salz", ""),
|
||||
("1/2 Bund Petersilie", "0.5", None, "Bund", "Petersilie", ""),
|
||||
("2 1/2 Tassen Reis", "2.5", None, "Tassen", "Reis", ""),
|
||||
("2-3 Zehen Knoblauch", "2", "3", "Zehen", "Knoblauch", ""),
|
||||
("2 bis 3 EL Öl", "2", "3", "EL", "Öl", ""),
|
||||
("2 Eier", "2", None, "", "Eier", ""),
|
||||
("3 Essl. Zucker", "3", None, "EL", "Zucker", ""),
|
||||
("- 1 Prise Salz", "1", None, "Prise", "Salz", ""),
|
||||
("½ Zitrone, nur Saft", "0.5", None, "", "Zitrone", "nur Saft"),
|
||||
("Salz und Pfeffer", None, None, "", "Salz und Pfeffer", ""),
|
||||
("1 Dose Tomaten (400 g)", "1", None, "Dose", "Tomaten (400 g)", ""),
|
||||
],
|
||||
)
|
||||
def test_parse_ingredient_line(line, amount, amount_max, unit, name, note):
|
||||
item = parse_ingredient_line(line)
|
||||
assert item.amount == (Decimal(amount) if amount else None)
|
||||
assert item.amount_max == (Decimal(amount_max) if amount_max else None)
|
||||
assert (item.unit, item.name, item.note) == (unit, name, note)
|
||||
|
||||
|
||||
def test_parse_ingredients_with_groups():
|
||||
items = parse_ingredients("Für den Teig:\n200 g Mehl\n\nFür die Soße:\n1 Dose Tomaten\n")
|
||||
assert [(i.group, i.name) for i in items] == [("Für den Teig", "Mehl"), ("Für die Soße", "Tomaten")]
|
||||
|
||||
|
||||
def test_ingredient_text_roundtrip():
|
||||
text = "200 g Mehl, gesiebt\n2-3 Zehen Knoblauch\n\nFür die Soße:\n½ TL Salz\nPfeffer"
|
||||
items = parse_ingredients(text)
|
||||
assert parse_ingredients(ingredients_to_text(items)) == items
|
||||
|
||||
|
||||
def test_parse_steps_by_blank_lines():
|
||||
assert parse_steps("Ofen vorheizen.\nAuf 180 °C.\n\n2. Mischen.") == ["Ofen vorheizen. Auf 180 °C.", "Mischen."]
|
||||
|
||||
|
||||
def test_parse_steps_by_lines_and_strip_numbers():
|
||||
assert parse_steps("1. Schneiden\n2) Braten\nSchritt 3: Servieren") == ["Schneiden", "Braten", "Servieren"]
|
||||
|
||||
|
||||
def test_parse_tags_lowercase_unique():
|
||||
assert parse_tags("Pasta, #Vegetarisch, Schnell Gemacht ,pasta,") == ["pasta", "vegetarisch", "schnell gemacht"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected",
|
||||
[("2", "2"), ("0.5", "½"), ("1.5", "1 ½"), ("0.333", "⅓"), ("0.25", "¼"), ("2.37", "2,37"), ("1.999", "2")],
|
||||
)
|
||||
def test_format_amount(value, expected):
|
||||
assert format_amount(Decimal(value)) == expected
|
||||
|
||||
|
||||
def test_scale_amount_normalises_metric():
|
||||
assert scale_amount(Decimal(600), None, "g", Decimal(2)) == (Decimal("1.2"), None, "kg")
|
||||
assert scale_amount(Decimal(250), None, "ml", Decimal("0.5")) == (Decimal(125), None, "ml")
|
||||
assert scale_amount(Decimal(2), Decimal(3), "EL", Decimal(2)) == (Decimal(4), Decimal(6), "EL")
|
||||
assert scale_amount(None, None, "", Decimal(2)) == (None, None, "")
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Acceptance: a private recipe is reachable by its owner only – in no view and under no URL,
|
||||
including image URLs. Other users and admins get 404 (indistinguishable from "does not exist")."""
|
||||
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
|
||||
from recipes.models import Favorite, Recipe, RecipeImage
|
||||
|
||||
from .recipe_helpers import image_upload, make_recipe
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
SECRET_TITLE = "Geheimrezept Omas Gulasch"
|
||||
SECRET_INGREDIENT = "Zauberpaprika"
|
||||
SECRET_TAG = "geheimtag"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def private_recipe(user):
|
||||
recipe = make_recipe(user, title=SECRET_TITLE, visibility=Recipe.Visibility.PRIVATE,
|
||||
ingredients=f"500 g {SECRET_INGREDIENT}", tags=(SECRET_TAG,))
|
||||
owner_client(user).post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload()]})
|
||||
assert recipe.images.count() == 1
|
||||
return recipe
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def image(private_recipe):
|
||||
return RecipeImage.objects.get(recipe=private_recipe)
|
||||
|
||||
|
||||
@pytest.fixture(params=["other", "admin"])
|
||||
def outsider(request, other_user, admin_user):
|
||||
c = Client()
|
||||
c.force_login(other_user if request.param == "other" else admin_user)
|
||||
return c
|
||||
|
||||
|
||||
def owner_client(user):
|
||||
c = Client()
|
||||
c.force_login(user)
|
||||
return c
|
||||
|
||||
|
||||
def get_urls(recipe, image):
|
||||
return [
|
||||
reverse("recipes:detail", args=[recipe.pk]),
|
||||
reverse("recipes:detail", args=[recipe.pk]) + "?portionen=8",
|
||||
reverse("recipes:edit", args=[recipe.pk]),
|
||||
reverse("recipes:delete", args=[recipe.pk]),
|
||||
reverse("recipes:photo", args=[image.uuid, "gross"]),
|
||||
reverse("recipes:photo", args=[image.uuid, "klein"]),
|
||||
*extra_get_urls(recipe, image),
|
||||
]
|
||||
|
||||
|
||||
def post_urls(recipe, image):
|
||||
return [
|
||||
reverse("recipes:edit", args=[recipe.pk]),
|
||||
reverse("recipes:delete", args=[recipe.pk]),
|
||||
reverse("recipes:favorite", args=[recipe.pk]),
|
||||
reverse("recipes:photo_upload", args=[recipe.pk]),
|
||||
reverse("recipes:photo_cover", args=[recipe.pk, image.pk]),
|
||||
reverse("recipes:photo_delete", args=[recipe.pk, image.pk]),
|
||||
*extra_post_urls(recipe, image),
|
||||
]
|
||||
|
||||
|
||||
def extra_get_urls(recipe, image):
|
||||
"""Hook for later features (comments, cooking mode, …)."""
|
||||
return []
|
||||
|
||||
|
||||
def extra_post_urls(recipe, image):
|
||||
return []
|
||||
|
||||
|
||||
def test_owner_sees_everything(user, private_recipe, image):
|
||||
c = owner_client(user)
|
||||
for url in get_urls(private_recipe, image):
|
||||
assert c.get(url).status_code == 200, url
|
||||
assert SECRET_TITLE in c.get(reverse("recipes:list")).content.decode()
|
||||
|
||||
|
||||
def test_outsider_gets_404_on_every_get_url(outsider, private_recipe, image):
|
||||
for url in get_urls(private_recipe, image):
|
||||
assert outsider.get(url).status_code == 404, url
|
||||
|
||||
|
||||
def test_outsider_gets_404_on_every_post_url(outsider, private_recipe, image):
|
||||
for url in post_urls(private_recipe, image):
|
||||
response = outsider.post(url, {"title": "gehackt", "servings": 1, "visibility": "public", "text": "x"})
|
||||
assert response.status_code == 404, url
|
||||
private_recipe.refresh_from_db()
|
||||
assert private_recipe.title == SECRET_TITLE and not private_recipe.is_public
|
||||
assert RecipeImage.objects.filter(pk=image.pk).exists()
|
||||
assert not Favorite.objects.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query",
|
||||
[
|
||||
{},
|
||||
{"q": "Geheimrezept"},
|
||||
{"q": SECRET_INGREDIENT},
|
||||
{"q": SECRET_TAG},
|
||||
{"zutat": SECRET_INGREDIENT},
|
||||
{"tag": SECRET_TAG},
|
||||
{"ansicht": "favoriten"},
|
||||
],
|
||||
)
|
||||
def test_private_recipe_absent_from_list_and_search(outsider, private_recipe, image, query):
|
||||
html = outsider.get(reverse("recipes:list"), query).content.decode()
|
||||
assert SECRET_TITLE not in html
|
||||
assert str(image.uuid) not in html
|
||||
assert reverse("recipes:detail", args=[private_recipe.pk]) not in html
|
||||
|
||||
|
||||
def test_private_tag_not_listed(outsider, private_recipe):
|
||||
assert f"#{SECRET_TAG}" not in outsider.get(reverse("recipes:list")).content.decode()
|
||||
|
||||
|
||||
def test_existing_favorite_disappears_when_recipe_turns_private(user, other_user):
|
||||
recipe = make_recipe(user, title="Bald privat")
|
||||
Favorite.objects.create(user=other_user, recipe=recipe)
|
||||
recipe.visibility = Recipe.Visibility.PRIVATE
|
||||
recipe.save()
|
||||
c = owner_client(other_user)
|
||||
assert "Bald privat" not in c.get(reverse("recipes:list"), {"ansicht": "favoriten"}).content.decode()
|
||||
assert c.get(reverse("recipes:detail", args=[recipe.pk])).status_code == 404
|
||||
|
||||
|
||||
def test_anonymous_gets_redirected_everywhere(private_recipe, image):
|
||||
c = Client()
|
||||
for url in get_urls(private_recipe, image):
|
||||
response = c.get(url)
|
||||
assert response.status_code == 302 and "/anmelden/" in response["Location"], url
|
||||
|
||||
|
||||
def test_visible_to_queryset_rule(user, other_user, admin_user, private_recipe):
|
||||
public = make_recipe(user, title="Öffentlich")
|
||||
assert set(Recipe.objects.visible_to(user)) == {private_recipe, public}
|
||||
assert set(Recipe.objects.visible_to(other_user)) == {public}
|
||||
assert set(Recipe.objects.visible_to(admin_user)) == {public}
|
||||
@@ -0,0 +1,190 @@
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
from PIL import Image
|
||||
|
||||
from recipes.models import Favorite, Recipe, RecipeImage
|
||||
|
||||
from .recipe_helpers import form_data, image_upload, make_recipe
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_create_recipe_with_photos(user_client, user, media_root):
|
||||
data = form_data(photos=[image_upload("a.jpg"), image_upload("b.png", fmt="PNG")])
|
||||
response = user_client.post(reverse("recipes:create"), data)
|
||||
recipe = Recipe.objects.get()
|
||||
assert response.status_code == 302 and response["Location"] == reverse("recipes:detail", args=[recipe.pk])
|
||||
assert recipe.owner == user and recipe.visibility == "private"
|
||||
assert [i.name for i in recipe.ingredients.all()] == ["Mehl", "Milch", "Eier", "Salz"]
|
||||
assert [s.text for s in recipe.steps.all()] == ["Alles verrühren.", "In der Pfanne ausbacken."]
|
||||
assert sorted(t.name for t in recipe.tags.all()) == ["frühstück", "süß"]
|
||||
imgs = list(recipe.images.all())
|
||||
assert len(imgs) == 2 and sum(i.is_cover for i in imgs) == 1
|
||||
for img in imgs:
|
||||
assert (Path(media_root) / img.file).exists() and (Path(media_root) / img.thumb).exists()
|
||||
assert img.width <= 1600 and img.height <= 1600
|
||||
|
||||
|
||||
def test_detail_shows_structured_recipe(user_client, user):
|
||||
recipe = make_recipe(user)
|
||||
html = user_client.get(reverse("recipes:detail", args=[recipe.pk])).content.decode()
|
||||
assert "Mehl" in html and "Backen." in html and "#kuchen" in html
|
||||
|
||||
|
||||
def test_edit_form_roundtrip(user_client, user):
|
||||
user_client.post(reverse("recipes:create"), form_data())
|
||||
recipe = Recipe.objects.get()
|
||||
form = user_client.get(reverse("recipes:edit", args=[recipe.pk])).context["form"]
|
||||
data = form_data(ingredients_text=form.initial["ingredients_text"], steps_text=form.initial["steps_text"],
|
||||
tags_text=form.initial["tags_text"], title="Pfannkuchen deluxe")
|
||||
user_client.post(reverse("recipes:edit", args=[recipe.pk]), data)
|
||||
recipe.refresh_from_db()
|
||||
assert recipe.title == "Pfannkuchen deluxe"
|
||||
assert [i.name for i in recipe.ingredients.all()] == ["Mehl", "Milch", "Eier", "Salz"]
|
||||
|
||||
|
||||
def test_only_owner_can_edit_public_recipe(client, user, other_user):
|
||||
recipe = make_recipe(user)
|
||||
client.force_login(other_user)
|
||||
assert client.get(reverse("recipes:detail", args=[recipe.pk])).status_code == 200
|
||||
assert client.get(reverse("recipes:edit", args=[recipe.pk])).status_code == 404
|
||||
assert client.post(reverse("recipes:delete", args=[recipe.pk])).status_code == 404
|
||||
|
||||
|
||||
def test_admin_can_delete_public_recipe(admin_client, user, media_root):
|
||||
recipe = make_recipe(user)
|
||||
admin_client.post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload()]})
|
||||
assert not RecipeImage.objects.exists() # admin is not the owner → upload refused (404)
|
||||
assert admin_client.post(reverse("recipes:delete", args=[recipe.pk])).status_code == 302
|
||||
assert not Recipe.objects.exists()
|
||||
|
||||
|
||||
def test_delete_removes_image_files(user_client, user, media_root):
|
||||
user_client.post(reverse("recipes:create"), form_data(photos=[image_upload()]))
|
||||
recipe = Recipe.objects.get()
|
||||
img = recipe.images.get()
|
||||
user_client.post(reverse("recipes:delete", args=[recipe.pk]))
|
||||
assert not (Path(media_root) / img.file).exists()
|
||||
|
||||
|
||||
def test_upload_rejects_non_image(user_client):
|
||||
fake = SimpleUploadedFile("evil.jpg", b"<?php echo 1; ?>", content_type="image/jpeg")
|
||||
response = user_client.post(reverse("recipes:create"), form_data(photos=[fake]))
|
||||
assert response.status_code == 200
|
||||
assert "kein unterstütztes Bild" in response.content.decode()
|
||||
assert not Recipe.objects.exists()
|
||||
|
||||
|
||||
def test_upload_rejects_too_large(user_client, settings):
|
||||
settings.IMAGE_MAX_UPLOAD_MB = 0
|
||||
response = user_client.post(reverse("recipes:create"), form_data(photos=[image_upload()]))
|
||||
assert "zu groß" in response.content.decode()
|
||||
|
||||
|
||||
def test_upload_rejects_gif(user_client):
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGB", (10, 10)).save(buffer, "GIF")
|
||||
gif = SimpleUploadedFile("a.gif", buffer.getvalue(), content_type="image/gif")
|
||||
response = user_client.post(reverse("recipes:create"), form_data(photos=[gif]))
|
||||
assert "kein unterstütztes Bild" in response.content.decode()
|
||||
|
||||
|
||||
def test_uploaded_image_has_no_exif(user_client, media_root):
|
||||
buffer = io.BytesIO()
|
||||
img = Image.new("RGB", (800, 600))
|
||||
exif = Image.Exif()
|
||||
exif[0x8825] = {1: "N", 2: (52.0, 31.0, 0.0)} # GPS info
|
||||
exif[0x010F] = "SpyCam"
|
||||
img.save(buffer, "JPEG", exif=exif)
|
||||
upload = SimpleUploadedFile("gps.jpg", buffer.getvalue(), content_type="image/jpeg")
|
||||
user_client.post(reverse("recipes:create"), form_data(photos=[upload]))
|
||||
stored = Image.open(Path(media_root) / RecipeImage.objects.get().file)
|
||||
assert stored.format == "WEBP"
|
||||
assert not stored.getexif()
|
||||
|
||||
|
||||
def test_image_limit_per_recipe(user_client, user, settings):
|
||||
settings.IMAGE_MAX_PER_RECIPE = 2
|
||||
recipe = make_recipe(user)
|
||||
user_client.post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload(), image_upload()]})
|
||||
user_client.post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload()]})
|
||||
assert recipe.images.count() == 2
|
||||
|
||||
|
||||
def test_cover_switch_and_delete(user_client, user):
|
||||
recipe = make_recipe(user)
|
||||
user_client.post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload(), image_upload()]})
|
||||
first, second = recipe.images.order_by("position")
|
||||
assert first.is_cover
|
||||
user_client.post(reverse("recipes:photo_cover", args=[recipe.pk, second.pk]))
|
||||
assert recipe.images.get(is_cover=True) == second
|
||||
user_client.post(reverse("recipes:photo_delete", args=[recipe.pk, second.pk]))
|
||||
assert recipe.images.get().is_cover
|
||||
|
||||
|
||||
def test_search_by_title_ingredient_and_tag(user_client, user):
|
||||
make_recipe(user, title="Linsensuppe", ingredients="200 g rote Linsen", tags=("suppe",))
|
||||
make_recipe(user, title="Tomatensalat", ingredients="4 Tomaten", tags=("salat",))
|
||||
|
||||
def titles(**q):
|
||||
return [r.title for r in user_client.get(reverse("recipes:list"), q).context["page"].object_list]
|
||||
|
||||
assert titles(q="linsen") == ["Linsensuppe"]
|
||||
assert titles(q="TOMATEN") == ["Tomatensalat"]
|
||||
assert titles(q="suppe") == ["Linsensuppe"]
|
||||
assert titles(zutat="linsen") == ["Linsensuppe"]
|
||||
assert titles(tag="salat") == ["Tomatensalat"]
|
||||
assert titles(q="linsen tomaten") == []
|
||||
|
||||
|
||||
def test_tags_stored_lowercase(user):
|
||||
recipe = make_recipe(user, tags=("Vegan", "SCHNELL"))
|
||||
assert sorted(t.name for t in recipe.tags.all()) == ["schnell", "vegan"]
|
||||
|
||||
|
||||
def test_favorites(user_client, user, other_user):
|
||||
recipe = make_recipe(other_user, title="Fremdes Rezept")
|
||||
make_recipe(other_user, title="Anderes")
|
||||
user_client.post(reverse("recipes:favorite", args=[recipe.pk]))
|
||||
assert Favorite.objects.filter(user=user, recipe=recipe).exists()
|
||||
titles = [r.title for r in user_client.get(reverse("recipes:list"), {"ansicht": "favoriten"}).context["page"].object_list]
|
||||
assert titles == ["Fremdes Rezept"]
|
||||
user_client.post(reverse("recipes:favorite", args=[recipe.pk]))
|
||||
assert not Favorite.objects.exists()
|
||||
|
||||
|
||||
def test_favorite_redirect_is_local_only(user_client, user):
|
||||
recipe = make_recipe(user)
|
||||
response = user_client.post(reverse("recipes:favorite", args=[recipe.pk]), {"next": "https://evil.example/"})
|
||||
assert response["Location"] == reverse("recipes:detail", args=[recipe.pk])
|
||||
|
||||
|
||||
def test_mine_filter(user_client, user, other_user):
|
||||
make_recipe(user, title="Meins")
|
||||
make_recipe(other_user, title="Deins")
|
||||
titles = [r.title for r in user_client.get(reverse("recipes:list"), {"ansicht": "meine"}).context["page"].object_list]
|
||||
assert titles == ["Meins"]
|
||||
|
||||
|
||||
def test_admin_deletes_recipes_of_deactivated_user(admin_client, user):
|
||||
make_recipe(user, visibility=Recipe.Visibility.PRIVATE)
|
||||
make_recipe(user)
|
||||
url = reverse("accounts:admin_user_delete_recipes", args=[user.pk])
|
||||
assert admin_client.get(url).status_code == 404 # only for deactivated users
|
||||
user.is_active = False
|
||||
user.save()
|
||||
assert admin_client.get(url).context["count"] == 2
|
||||
admin_client.post(url)
|
||||
assert not Recipe.objects.exists()
|
||||
|
||||
|
||||
def test_user_cannot_bulk_delete(user_client, other_user):
|
||||
other_user.is_active = False
|
||||
other_user.save()
|
||||
make_recipe(other_user)
|
||||
assert user_client.post(reverse("accounts:admin_user_delete_recipes", args=[other_user.pk])).status_code == 403
|
||||
assert Recipe.objects.exists()
|
||||
Reference in New Issue
Block a user