feat: species search with HTMX and Perenual caching on select

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Stephan Kerkman 2026-05-28 10:04:02 +02:00
parent ef84af433e
commit 88976bb351
5 changed files with 141 additions and 1 deletions

View file

@ -0,0 +1,28 @@
{% for result in results %}
<div class="list-group-item">
<div class="d-flex align-items-center gap-2 mb-1">
{% if result.default_image and result.default_image.thumbnail %}
<img src="{{ result.default_image.thumbnail }}" width="40" height="40" class="rounded" style="object-fit:cover; flex-shrink:0;">
{% else %}
<div style="width:40px; height:40px; background:#d8f3dc; border-radius:.375rem; flex-shrink:0; display:flex; align-items:center; justify-content:center;">🌿</div>
{% endif %}
<div>
<div class="fw-semibold">{{ result.common_name }}</div>
<small class="text-muted fst-italic">{{ result.scientific_name|join:", " }}</small>
</div>
</div>
<form method="POST" action="{% url 'species_select' %}">
{% csrf_token %}
<input type="hidden" name="perenual_id" value="{{ result.id }}">
<input type="hidden" name="common_name" value="{{ result.common_name }}">
<input type="hidden" name="scientific_name" value="{{ result.scientific_name|join:', ' }}">
<input type="hidden" name="watering" value="{{ result.watering|default:'' }}">
<input type="hidden" name="sunlight" value="{{ result.sunlight|join:', ' }}">
<input type="hidden" name="pruning_months" value="{{ result.pruning_month|join:', ' }}">
<input type="hidden" name="api_image_url" value="{{ result.default_image.regular_url|default:'' }}">
<button type="submit" class="btn btn-sm btn-success">Select →</button>
</form>
</div>
{% empty %}
{% if q %}<p class="list-group-item text-muted">No results for "{{ q }}"</p>{% endif %}
{% endfor %}

View file

@ -0,0 +1,26 @@
{% extends "plants/base.html" %}
{% block title %}Add Plant — PlantDB{% endblock %}
{% block content %}
<h5 class="mb-3">Add a plant</h5>
<p class="text-muted small">Search for the species to auto-fill care info, or skip to enter details manually.</p>
<input
type="search"
class="form-control mb-2"
placeholder="🔍 Search species, e.g. 'climbing rose'"
hx-get="{% url 'species_search' %}"
hx-trigger="input changed delay:400ms"
hx-target="#species-results"
hx-swap="innerHTML"
name="q"
autocomplete="off"
>
<div id="species-results" class="list-group mb-3"></div>
<div class="text-center">
<a href="{% url 'plant_add' %}?species_id=skip" class="text-muted small">
Skip — enter details manually
</a>
</div>
{% endblock %}

View file

@ -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

View file

@ -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/<int:pk>/delete/', plants.plant_delete, name='plant_delete'),
path('plants/<int:pk>/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'),
]

40
plants/views/species.py Normal file
View file

@ -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}")