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': ''},
|
||||
)
|
||||
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 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
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