From 88976bb35118cf3173c516b054c903f60b0fe1a7 Mon Sep 17 00:00:00 2001 From: Stephan Kerkman Date: Thu, 28 May 2026 10:04:02 +0200 Subject: [PATCH] feat: species search with HTMX and Perenual caching on select Co-Authored-By: Claude Sonnet 4.6 --- .../plants/partials/species_results.html | 28 ++++++++++++ plants/templates/plants/species_search.html | 26 +++++++++++ plants/tests/test_views.py | 44 +++++++++++++++++++ plants/urls.py | 4 +- plants/views/species.py | 40 +++++++++++++++++ 5 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 plants/templates/plants/partials/species_results.html create mode 100644 plants/templates/plants/species_search.html create mode 100644 plants/views/species.py diff --git a/plants/templates/plants/partials/species_results.html b/plants/templates/plants/partials/species_results.html new file mode 100644 index 0000000..7ec09ab --- /dev/null +++ b/plants/templates/plants/partials/species_results.html @@ -0,0 +1,28 @@ +{% for result in results %} +
+
+ {% if result.default_image and result.default_image.thumbnail %} + + {% else %} +
🌿
+ {% endif %} +
+
{{ result.common_name }}
+ {{ result.scientific_name|join:", " }} +
+
+
+ {% csrf_token %} + + + + + + + + +
+
+{% empty %} +{% if q %}

No results for "{{ q }}"

{% endif %} +{% endfor %} diff --git a/plants/templates/plants/species_search.html b/plants/templates/plants/species_search.html new file mode 100644 index 0000000..8ac15c4 --- /dev/null +++ b/plants/templates/plants/species_search.html @@ -0,0 +1,26 @@ +{% extends "plants/base.html" %} +{% block title %}Add Plant — PlantDB{% endblock %} +{% block content %} +
Add a plant
+

Search for the species to auto-fill care info, or skip to enter details manually.

+ + + +
+ + +{% endblock %} diff --git a/plants/tests/test_views.py b/plants/tests/test_views.py index e7199da..0563bab 100644 --- a/plants/tests/test_views.py +++ b/plants/tests/test_views.py @@ -105,3 +105,47 @@ class TestLogPruning: {'pruned_on': '2026-05-01', 'notes': ''}, ) assert resp.templates[0].name == 'plants/partials/pruning_strip.html' + + +@pytest.mark.django_db +class TestSpeciesSearch: + def test_search_page_returns_200(self, client): + resp = client.get(reverse('plant_add')) + assert resp.status_code == 200 + + def test_htmx_search_returns_partial(self, client, settings): + settings.PERENUAL_API_KEY = '' + resp = client.get( + reverse('species_search') + '?q=rose', + HTTP_HX_REQUEST='true', + ) + assert resp.status_code == 200 + assert resp.templates[0].name == 'plants/partials/species_results.html' + + def test_species_select_creates_species_and_redirects(self, client): + resp = client.post(reverse('species_select'), { + 'perenual_id': '42', + 'common_name': 'Test Rose', + 'scientific_name': 'Rosa testii', + 'watering': 'Weekly', + 'sunlight': 'Full sun', + 'pruning_months': 'February, August', + 'api_image_url': '', + }) + assert resp.status_code == 302 + from plants.models import Species + assert Species.objects.filter(perenual_id=42).exists() + + def test_species_select_deduplicates(self, client): + from plants.models import Species + Species.objects.create(perenual_id=99, common_name='Existing') + client.post(reverse('species_select'), { + 'perenual_id': '99', + 'common_name': 'Existing', + 'scientific_name': '', + 'watering': '', + 'sunlight': '', + 'pruning_months': '', + 'api_image_url': '', + }) + assert Species.objects.filter(perenual_id=99).count() == 1 diff --git a/plants/urls.py b/plants/urls.py index 4a0611f..b710f20 100644 --- a/plants/urls.py +++ b/plants/urls.py @@ -1,5 +1,5 @@ from django.urls import path -from plants.views import dashboard, plants, pruning +from plants.views import dashboard, plants, pruning, species urlpatterns = [ path('', dashboard.dashboard, name='dashboard'), @@ -10,4 +10,6 @@ urlpatterns = [ path('plants//delete/', plants.plant_delete, name='plant_delete'), path('plants//log-pruning/', plants.log_pruning, name='log_pruning'), path('pruning/', pruning.pruning_calendar, name='pruning_calendar'), + path('species/search/', species.species_search, name='species_search'), + path('species/select/', species.species_select, name='species_select'), ] diff --git a/plants/views/species.py b/plants/views/species.py new file mode 100644 index 0000000..9a5cfdd --- /dev/null +++ b/plants/views/species.py @@ -0,0 +1,40 @@ +from django.shortcuts import render, redirect +from django.urls import reverse +from plants.models import Species +from plants.services.perenual import search_species + +_MONTH_MAP = { + 'January': 1, 'February': 2, 'March': 3, 'April': 4, + 'May': 5, 'June': 6, 'July': 7, 'August': 8, + 'September': 9, 'October': 10, 'November': 11, 'December': 12, +} + + +def species_search(request): + q = request.GET.get('q', '').strip() + results = search_species(q) if len(q) >= 2 else [] + return render(request, 'plants/partials/species_results.html', { + 'results': results, 'q': q, + }) + + +def species_select(request): + perenual_id = int(request.POST['perenual_id']) + if not Species.objects.filter(perenual_id=perenual_id).exists(): + pruning_str = request.POST.get('pruning_months', '') + pruning_months = [ + _MONTH_MAP[m.strip()] + for m in pruning_str.split(',') + if m.strip() in _MONTH_MAP + ] + Species.objects.create( + perenual_id=perenual_id, + common_name=request.POST.get('common_name', ''), + scientific_name=request.POST.get('scientific_name', ''), + watering=request.POST.get('watering', ''), + sunlight=request.POST.get('sunlight', ''), + pruning_months=pruning_months, + api_image_url=request.POST.get('api_image_url', ''), + ) + species = Species.objects.get(perenual_id=perenual_id) + return redirect(f"{reverse('plant_add')}?species_id={species.pk}")