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:
parent
ef84af433e
commit
88976bb351
5 changed files with 141 additions and 1 deletions
28
plants/templates/plants/partials/species_results.html
Normal file
28
plants/templates/plants/partials/species_results.html
Normal 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 %}
|
||||||
26
plants/templates/plants/species_search.html
Normal file
26
plants/templates/plants/species_search.html
Normal 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 %}
|
||||||
|
|
@ -105,3 +105,47 @@ class TestLogPruning:
|
||||||
{'pruned_on': '2026-05-01', 'notes': ''},
|
{'pruned_on': '2026-05-01', 'notes': ''},
|
||||||
)
|
)
|
||||||
assert resp.templates[0].name == 'plants/partials/pruning_strip.html'
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from plants.views import dashboard, plants, pruning
|
from plants.views import dashboard, plants, pruning, species
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', dashboard.dashboard, name='dashboard'),
|
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>/delete/', plants.plant_delete, name='plant_delete'),
|
||||||
path('plants/<int:pk>/log-pruning/', plants.log_pruning, name='log_pruning'),
|
path('plants/<int:pk>/log-pruning/', plants.log_pruning, name='log_pruning'),
|
||||||
path('pruning/', pruning.pruning_calendar, name='pruning_calendar'),
|
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
40
plants/views/species.py
Normal 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}")
|
||||||
Loading…
Add table
Reference in a new issue