- recipes/ai.py: Chat Completions with strict JSON schema (Structured Outputs), Pydantic re-validation, plausibility clamping, prompt treats input as data only - "Mit KI aufbereiten" on create/edit form returns an editable draft - Import page: photo/screenshot (vision, downscaled JPEG, not stored) and pasted text; drafts pre-fill the recipe form with a preview - Clear UI messages for timeout, rate limit, quota, auth, model and connection errors; without OPENAI_API_KEY AI features show a notice - Optional per-user and global daily AI limits (default unlimited) - Tests for response parsing, schema strictness, error mapping, views Co-Authored-By: Claude Opus 5.5 <[email protected]>
77 lines
2.8 KiB
HTML
77 lines
2.8 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Rezepte{% endblock %}
|
|
{% block content %}
|
|
<div class="page-head">
|
|
<h1>Rezepte</h1>
|
|
<div class="head-actions">
|
|
<a class="button secondary" href="{% url 'recipes:import' %}">Import</a>
|
|
<a class="button" href="{% url 'recipes:create' %}">+ Neu</a>
|
|
</div>
|
|
</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 %}
|