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:
Kyle Dammann
2026-10-30 21:37:13 +01:00
co-authored by Claude Opus 5.5
parent ef9deaeff7
commit 6f7e4f92ca
41 changed files with 1870 additions and 37 deletions
@@ -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 %}
+1
View File
@@ -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
View File
@@ -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>
-11
View File
@@ -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 %}
+1 -1
View File
@@ -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 %}
+1 -1
View File
@@ -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 %}
+6
View File
@@ -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>
+13
View File
@@ -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 %}
+85
View File
@@ -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 %}
+39
View File
@@ -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 %}
+73
View File
@@ -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 %}