From ef84af433e6d3820ca3c997e7d6632a73cf502d5 Mon Sep 17 00:00:00 2001 From: Stephan Kerkman Date: Thu, 28 May 2026 10:03:01 +0200 Subject: [PATCH] feat: plant detail with photo hero and inline HTMX pruning log Co-Authored-By: Claude Sonnet 4.6 --- .../plants/partials/pruning_strip.html | 35 ++++++++ plants/templates/plants/plant_detail.html | 81 +++++++++++++++++++ plants/tests/test_views.py | 38 +++++++++ plants/views/plants.py | 37 +++++++-- 4 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 plants/templates/plants/partials/pruning_strip.html create mode 100644 plants/templates/plants/plant_detail.html diff --git a/plants/templates/plants/partials/pruning_strip.html b/plants/templates/plants/partials/pruning_strip.html new file mode 100644 index 0000000..df5c0b7 --- /dev/null +++ b/plants/templates/plants/partials/pruning_strip.html @@ -0,0 +1,35 @@ +
+
+
+ ✂️ Pruning + {% if plant.pruning_months %} +
+ Scheduled: {{ plant.pruning_months|join:", " }} +
+ {% endif %} + {% with plant.pruning_logs.first as last_log %} + {% if last_log %} +
Last pruned: {{ last_log.pruned_on|date:"j M Y" }}
+ {% endif %} + {% endwith %} +
+ {% if pruning_status == 'overdue' %} + Overdue + {% elif pruning_status == 'due_this_month' %} + Due this month + {% endif %} +
+ +
+ {% csrf_token %} +
+ {{ log_form.pruned_on }} + +
+
+
diff --git a/plants/templates/plants/plant_detail.html b/plants/templates/plants/plant_detail.html new file mode 100644 index 0000000..3f8e756 --- /dev/null +++ b/plants/templates/plants/plant_detail.html @@ -0,0 +1,81 @@ +{% extends "plants/base.html" %} +{% block title %}{{ plant.name }} — PlantDB{% endblock %} +{% block content %} + +
+ +
{{ plant.name }}
+ ✏️ +
+ +{% if plant.photo %} +{{ plant.name }} +{% elif plant.species and plant.species.api_image_url %} +{{ plant.species.common_name }} +{% else %} +
🌿
+{% endif %} + +
+
+
{{ plant.name }}
+ {% if plant.species %}{{ plant.species.scientific_name }}{% endif %} +
+ + {% if plant.is_indoor %}Indoor{% else %}Outdoor{% endif %} + +
+ +

📍 {{ plant.location }}

+ +{% if plant.species %} +
+ {% if plant.species.watering %} + 💧 {{ plant.species.watering }} + {% endif %} + {% if plant.species.sunlight %} + ☀️ {{ plant.species.sunlight }} + {% endif %} + {% if plant.species.max_height_cm %} + 📏 up to {{ plant.species.max_height_cm }} cm + {% endif %} + {% if plant.species.growth_rate %} + 🌱 {{ plant.species.growth_rate }} + {% endif %} +
+ +{% if plant.species.api_image_url and plant.photo %} +
+ + Species reference: {{ plant.species.common_name }} +
+{% endif %} +{% endif %} + +{% include "plants/partials/pruning_strip.html" %} + +{% if plant.notes %} +
+
+ {{ plant.notes|linebreaksbr }} +
+
+{% endif %} + +{% if plant.pruning_logs.all %} +
+
Pruning history
+
    + {% for log in plant.pruning_logs.all %} +
  • + {{ log.pruned_on|date:"j M Y" }} + {% if log.notes %}{{ log.notes }}{% endif %} +
  • + {% endfor %} +
+
+{% endif %} + +Delete plant + +{% endblock %} diff --git a/plants/tests/test_views.py b/plants/tests/test_views.py index dee8134..e7199da 100644 --- a/plants/tests/test_views.py +++ b/plants/tests/test_views.py @@ -67,3 +67,41 @@ class TestPlantList: content = resp.content.decode() assert 'Fern' in content assert 'Rose' not in content + + +@pytest.mark.django_db +class TestPlantDetail: + def test_returns_200(self, client): + plant = Plant.objects.create(name='Fern', location='Office') + resp = client.get(reverse('plant_detail', args=[plant.pk])) + assert resp.status_code == 200 + + def test_404_for_missing_plant(self, client): + resp = client.get(reverse('plant_detail', args=[9999])) + assert resp.status_code == 404 + + def test_context_contains_plant_and_status(self, client): + plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[]) + resp = client.get(reverse('plant_detail', args=[plant.pk])) + assert resp.context['plant'] == plant + assert resp.context['pruning_status'] == 'no_schedule' + + +@pytest.mark.django_db +class TestLogPruning: + def test_creates_pruning_log(self, client): + plant = Plant.objects.create(name='Rose', location='Garden') + resp = client.post( + reverse('log_pruning', args=[plant.pk]), + {'pruned_on': '2026-05-01', 'notes': ''}, + ) + assert resp.status_code == 200 + assert PruningLog.objects.filter(plant=plant, pruned_on='2026-05-01').exists() + + def test_returns_pruning_strip_partial(self, client): + plant = Plant.objects.create(name='Rose', location='Garden') + resp = client.post( + reverse('log_pruning', args=[plant.pk]), + {'pruned_on': '2026-05-01', 'notes': ''}, + ) + assert resp.templates[0].name == 'plants/partials/pruning_strip.html' diff --git a/plants/views/plants.py b/plants/views/plants.py index 6162f57..5e6dae2 100644 --- a/plants/views/plants.py +++ b/plants/views/plants.py @@ -27,11 +27,39 @@ def plant_list(request): def plant_detail(request, pk): - pass + plant = get_object_or_404( + Plant.objects.select_related('species').prefetch_related('pruning_logs'), + pk=pk, + ) + today = date.today() + return render(request, 'plants/plant_detail.html', { + 'plant': plant, + 'pruning_status': pruning_status(plant, today), + 'log_form': PruningLogForm(initial={'pruned_on': today}), + 'today': today, + }) + + +@require_POST +def log_pruning(request, pk): + plant = get_object_or_404( + Plant.objects.prefetch_related('pruning_logs'), pk=pk + ) + form = PruningLogForm(request.POST) + if form.is_valid(): + log = form.save(commit=False) + log.plant = plant + log.save() + today = date.today() + return render(request, 'plants/partials/pruning_strip.html', { + 'plant': plant, + 'pruning_status': pruning_status(plant, today), + 'log_form': PruningLogForm(initial={'pruned_on': today}), + }) def plant_add(request): - pass + return render(request, 'plants/species_search.html') def plant_edit(request, pk): @@ -40,8 +68,3 @@ def plant_edit(request, pk): def plant_delete(request, pk): pass - - -@require_POST -def log_pruning(request, pk): - pass