49 KiB
Pl@ntNet Photo ID Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the Perenual + VPC text-search entry point with a Pl@ntNet photo-identification flow that lets users identify unknown plants by photo, then review and pick enrichment data field-by-field from Pl@ntNet and VPC.
Architecture: New 3-step flow at /identify/ (upload → species confirm → field picker) backed by a new plantnet.py service and identify.py view module. Django session carries state between steps; temp images stored via default_storage and deleted on completion. VPC enrichment runs automatically after species confirm. Card scan flow is unchanged.
Tech Stack: Django 5.2 · Bootstrap 5.3 · requests · Pl@ntNet API v2 (my-api.plantnet.org) · Django sessions · pytest-django · unittest.mock
File Map
| Action | Path | Responsibility |
|---|---|---|
| Create | plants/services/plantnet.py |
Pl@ntNet API wrapper |
| Create | plants/views/identify.py |
3-step identify views |
| Create | plants/templates/plants/identify_upload.html |
Step 1: photo upload UI |
| Create | plants/templates/plants/identify_confirm.html |
Step 2: species picker UI |
| Create | plants/templates/plants/identify_fields.html |
Step 3: field picker + save UI |
| Create | plants/tests/test_plantnet.py |
Service unit tests |
| Create | plants/tests/test_identify_views.py |
View integration tests |
| Modify | plants/urls.py |
Add 3 identify URLs |
| Modify | plantdb/settings.py |
Add PLANTNET_API_KEY |
| Modify | plants/templates/plants/base.html |
"+" → /identify/ |
| Modify | plants/templates/plants/species_search.html |
Remove Perenual checkbox |
| Modify | plants/templates/plants/plant_detail.html |
Fix delete button alignment |
Task 1: Settings
Files:
- Modify:
plantdb/settings.py
Note: .superpowers/ is already in .gitignore — nothing to do there.
- Step 1: Add
PLANTNET_API_KEYto settings
In plantdb/settings.py, after the PERENUAL_API_KEY line:
PLANTNET_API_KEY = os.environ.get('PLANTNET_API_KEY', '')
- Step 2: Commit
git add plantdb/settings.py
git commit -m "feat: add PLANTNET_API_KEY setting"
Task 2: Pl@ntNet service (TDD)
Files:
-
Create:
plants/services/plantnet.py -
Create:
plants/tests/test_plantnet.py -
Step 1: Write failing tests
Create plants/tests/test_plantnet.py:
import pytest
import requests as req_lib
from unittest.mock import patch, MagicMock
from plants.services.plantnet import identify, PlantNetError
def _mock_response(results=None, status_code=200):
mock = MagicMock()
mock.status_code = status_code
mock.json.return_value = {'results': results or []}
if status_code == 429:
mock.raise_for_status.side_effect = req_lib.HTTPError(response=mock)
elif status_code >= 400:
mock.raise_for_status.side_effect = req_lib.HTTPError(response=mock)
else:
mock.raise_for_status = MagicMock()
return mock
def _api_results():
return [
{
'score': 0.94,
'species': {
'scientificNameWithoutAuthor': 'Liatris spicata',
'commonNames': ['Blazing star', 'Pronkster'],
'family': {'scientificNameWithoutAuthor': 'Asteraceae'},
'genus': {'scientificNameWithoutAuthor': 'Liatris'},
},
'gbif': {'id': '2889739'},
},
{
'score': 0.04,
'species': {
'scientificNameWithoutAuthor': 'Liatris pycnostachya',
'commonNames': ['Prairie blazing star'],
'family': {'scientificNameWithoutAuthor': 'Asteraceae'},
'genus': {'scientificNameWithoutAuthor': 'Liatris'},
},
'gbif': {'id': '1234567'},
},
]
def test_identify_returns_empty_without_api_key(settings):
settings.PLANTNET_API_KEY = ''
with pytest.raises(PlantNetError, match='API key'):
identify([(b'fake', 'flower')])
def test_identify_parses_results(settings):
settings.PLANTNET_API_KEY = 'test-key'
with patch('plants.services.plantnet.requests.post',
return_value=_mock_response(_api_results())):
results = identify([(b'fake', 'flower')])
assert len(results) == 2
assert results[0]['scientific_name'] == 'Liatris spicata'
assert results[0]['score'] == pytest.approx(0.94)
assert 'Blazing star' in results[0]['common_names']
assert results[0]['family'] == 'Asteraceae'
assert results[0]['genus'] == 'Liatris'
assert results[0]['gbif_id'] == '2889739'
def test_identify_raises_on_no_results(settings):
settings.PLANTNET_API_KEY = 'test-key'
with patch('plants.services.plantnet.requests.post',
return_value=_mock_response([])):
with pytest.raises(PlantNetError, match='No match'):
identify([(b'fake', 'flower')])
def test_identify_raises_on_quota_exceeded(settings):
settings.PLANTNET_API_KEY = 'test-key'
with patch('plants.services.plantnet.requests.post',
return_value=_mock_response(status_code=429)):
with pytest.raises(PlantNetError, match='Daily limit'):
identify([(b'fake', 'flower')])
def test_identify_raises_on_timeout(settings):
settings.PLANTNET_API_KEY = 'test-key'
with patch('plants.services.plantnet.requests.post',
side_effect=req_lib.Timeout):
with pytest.raises(PlantNetError, match='timed out'):
identify([(b'fake', 'flower')])
def test_identify_raises_on_other_http_error(settings):
settings.PLANTNET_API_KEY = 'test-key'
with patch('plants.services.plantnet.requests.post',
return_value=_mock_response(status_code=500)):
with pytest.raises(PlantNetError):
identify([(b'fake', 'flower')])
def test_identify_sends_correct_organs(settings):
settings.PLANTNET_API_KEY = 'test-key'
mock_post = MagicMock(return_value=_mock_response(_api_results()))
with patch('plants.services.plantnet.requests.post', mock_post):
identify([(b'img1', 'flower'), (b'img2', 'leaf')])
call_kwargs = mock_post.call_args
data = call_kwargs[1]['data']
assert ('organs', 'flower') in data
assert ('organs', 'leaf') in data
- Step 2: Run tests to confirm they fail
cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_plantnet.py -v
Expected: ERROR — plants.services.plantnet module not found.
- Step 3: Implement
plants/services/plantnet.py
import requests
from django.conf import settings
_API_BASE = 'https://my-api.plantnet.org/v2/identify/all'
_TIMEOUT = 15
class PlantNetError(Exception):
pass
def identify(images):
"""
Call Pl@ntNet identify API.
images: list of (image_bytes, organ) tuples
organ: 'flower' | 'leaf' | 'fruit' | 'bark' | 'habit' | 'auto'
Returns list of match dicts (up to 5), each:
scientific_name, common_names, score, gbif_id, family, genus
Raises PlantNetError on any failure.
"""
if not settings.PLANTNET_API_KEY:
raise PlantNetError('API key not configured.')
files = [
('images', (f'image{i}.jpg', img_bytes, 'image/jpeg'))
for i, (img_bytes, _) in enumerate(images)
]
data = [('organs', organ) for _, organ in images]
try:
resp = requests.post(
_API_BASE,
params={'api-key': settings.PLANTNET_API_KEY, 'lang': 'en'},
files=files,
data=data,
timeout=_TIMEOUT,
)
except requests.Timeout:
raise PlantNetError('Pl@ntNet timed out — try again.')
if resp.status_code == 429:
raise PlantNetError('Daily limit reached (500/day).')
try:
resp.raise_for_status()
except requests.HTTPError:
raise PlantNetError(f'Pl@ntNet API error ({resp.status_code}).')
results = resp.json().get('results', [])
if not results:
raise PlantNetError('No match found — try a different photo or organ.')
return [
{
'scientific_name': r['species']['scientificNameWithoutAuthor'],
'common_names': r['species'].get('commonNames', []),
'score': r['score'],
'gbif_id': (r.get('gbif') or {}).get('id'),
'family': r['species'].get('family', {}).get('scientificNameWithoutAuthor', ''),
'genus': r['species'].get('genus', {}).get('scientificNameWithoutAuthor', ''),
}
for r in results[:5]
]
- Step 4: Run tests to confirm they pass
cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_plantnet.py -v
Expected: all 7 tests PASS.
- Step 5: Commit
git add plants/services/plantnet.py plants/tests/test_plantnet.py
git commit -m "feat: add Pl@ntNet identification service"
Task 3: URL wiring + view stubs
Files:
-
Create:
plants/views/identify.py -
Modify:
plants/urls.py -
Step 1: Create view stubs
Create plants/views/identify.py:
from django.shortcuts import render, redirect
_SESSION_KEYS = [
'identify_matches', 'identify_image_paths', 'identify_selected',
'identify_vpc_results', 'identify_vpc_idx',
]
def _clear_session(request):
for key in _SESSION_KEYS:
request.session.pop(key, None)
def identify_upload(request):
if request.method == 'POST':
return redirect('identify_confirm')
return render(request, 'plants/identify_upload.html')
def identify_confirm(request):
if 'identify_matches' not in request.session:
return redirect('identify_upload')
if request.method == 'POST':
return redirect('identify_fields')
return render(request, 'plants/identify_confirm.html', {
'matches': request.session.get('identify_matches', []),
})
def identify_fields(request):
if 'identify_selected' not in request.session:
return redirect('identify_upload')
if request.method == 'POST':
_clear_session(request)
return redirect('plant_list')
return render(request, 'plants/identify_fields.html', {
'selected': request.session.get('identify_selected', {}),
'vpc_results': request.session.get('identify_vpc_results', []),
'vpc_idx': request.session.get('identify_vpc_idx', 0),
})
- Step 2: Register URLs
In plants/urls.py, add import and 3 paths:
from plants.views import dashboard, plants, pruning, species, identify
Add after the existing plants/scan/ path:
path('identify/', identify.identify_upload, name='identify_upload'),
path('identify/confirm/', identify.identify_confirm, name='identify_confirm'),
path('identify/fields/', identify.identify_fields, name='identify_fields'),
- Step 3: Create minimal templates so views render
Create plants/templates/plants/identify_upload.html:
{% extends "plants/base.html" %}
{% block title %}Identify plant — PlantDB{% endblock %}
{% block content %}
<p>Upload step (coming soon)</p>
{% endblock %}
Create plants/templates/plants/identify_confirm.html:
{% extends "plants/base.html" %}
{% block title %}Confirm species — PlantDB{% endblock %}
{% block content %}
<p>Confirm step (coming soon)</p>
{% endblock %}
Create plants/templates/plants/identify_fields.html:
{% extends "plants/base.html" %}
{% block title %}Review fields — PlantDB{% endblock %}
{% block content %}
<p>Fields step (coming soon)</p>
{% endblock %}
- Step 4: Verify server starts without errors
cd /home/stephan/devel/python/plantdb && python manage.py check
Expected: System check identified no issues (0 silenced).
- Step 5: Write guard tests
Add to plants/tests/test_identify_views.py:
import pytest
from django.test import Client
from django.urls import reverse
@pytest.fixture
def client():
return Client()
def test_upload_get(client):
resp = client.get(reverse('identify_upload'))
assert resp.status_code == 200
def test_confirm_redirects_without_session(client):
resp = client.get(reverse('identify_confirm'))
assert resp.status_code == 302
assert reverse('identify_upload') in resp['Location']
def test_fields_redirects_without_session(client):
resp = client.get(reverse('identify_fields'))
assert resp.status_code == 302
assert reverse('identify_upload') in resp['Location']
- Step 6: Run guard tests
cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_identify_views.py -v
Expected: 3 tests PASS.
- Step 7: Commit
git add plants/views/identify.py plants/urls.py \
plants/templates/plants/identify_upload.html \
plants/templates/plants/identify_confirm.html \
plants/templates/plants/identify_fields.html \
plants/tests/test_identify_views.py
git commit -m "feat: wire identify URL routes and stub views"
Task 4: Step 1 — Upload view + template
Files:
- Modify:
plants/views/identify.py - Modify:
plants/templates/plants/identify_upload.html - Modify:
plants/tests/test_identify_views.py
The upload view saves images to temp storage, calls the Pl@ntNet service, stores results in session.
- Step 1: Write failing upload view tests
Append to plants/tests/test_identify_views.py:
import io
from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile
def _fake_image(name='test.jpg'):
return SimpleUploadedFile(name, b'fake-image-bytes', content_type='image/jpeg')
def test_upload_post_no_images(client):
resp = client.post(reverse('identify_upload'), {})
assert resp.status_code == 200
assert b'select at least one' in resp.content.lower() or b'Please' in resp.content
def test_upload_post_calls_plantnet_and_redirects(client, settings):
settings.PLANTNET_API_KEY = 'test-key'
fake_matches = [
{'scientific_name': 'Liatris spicata', 'common_names': ['Blazing star'],
'score': 0.94, 'gbif_id': '123', 'family': 'Asteraceae', 'genus': 'Liatris'},
]
with patch('plants.views.identify.plantnet.identify', return_value=fake_matches):
with patch('plants.views.identify.default_storage') as mock_storage:
mock_storage.save.return_value = 'plants/temp_identify/abc_0.jpg'
mock_storage.path.return_value = '/tmp/abc_0.jpg'
resp = client.post(reverse('identify_upload'), {
'images': [_fake_image()],
'organs': ['flower'],
})
assert resp.status_code == 302
assert reverse('identify_confirm') in resp['Location']
assert client.session['identify_matches'] == fake_matches
def test_upload_post_plantnet_error_rerenders(client, settings):
settings.PLANTNET_API_KEY = 'test-key'
from plants.services.plantnet import PlantNetError
with patch('plants.views.identify.plantnet.identify',
side_effect=PlantNetError('No match found')):
with patch('plants.views.identify.default_storage') as mock_storage:
mock_storage.save.return_value = 'plants/temp_identify/abc_0.jpg'
mock_storage.path.return_value = '/tmp/abc_0.jpg'
mock_storage.exists.return_value = True
resp = client.post(reverse('identify_upload'), {
'images': [_fake_image()],
'organs': ['flower'],
})
assert resp.status_code == 200
assert b'No match found' in resp.content
- Step 2: Run to confirm tests fail
cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_identify_views.py::test_upload_post_no_images plants/tests/test_identify_views.py::test_upload_post_calls_plantnet_and_redirects plants/tests/test_identify_views.py::test_upload_post_plantnet_error_rerenders -v
Expected: FAIL.
- Step 3: Implement upload view
Replace identify_upload in plants/views/identify.py:
import uuid
from django.shortcuts import render, redirect
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from plants.services import plantnet
from plants.services.plantnet import PlantNetError
from plants.services.vpc import search_species as vpc_search
_SESSION_KEYS = [
'identify_matches', 'identify_image_paths', 'identify_selected',
'identify_vpc_results', 'identify_vpc_idx',
]
_VALID_ORGANS = {'flower', 'leaf', 'fruit', 'bark', 'habit', 'auto'}
def _clear_session(request):
for key in _SESSION_KEYS:
request.session.pop(key, None)
def identify_upload(request):
if request.method != 'POST':
return render(request, 'plants/identify_upload.html')
files = request.FILES.getlist('images')
organs = request.POST.getlist('organs')
if not files:
return render(request, 'plants/identify_upload.html',
{'error': 'Please select at least one photo.'})
scan_id = uuid.uuid4().hex
saved_paths = []
for i, f in enumerate(files[:5]):
organ = organs[i] if i < len(organs) else 'auto'
if organ not in _VALID_ORGANS:
organ = 'auto'
name = f'plants/temp_identify/{scan_id}_{i}.jpg'
default_storage.save(name, ContentFile(f.read()))
saved_paths.append({'path': name, 'organ': organ})
try:
images = [
(open(default_storage.path(p['path']), 'rb').read(), p['organ'])
for p in saved_paths
]
matches = plantnet.identify(images)
except PlantNetError as e:
for p in saved_paths:
if default_storage.exists(p['path']):
default_storage.delete(p['path'])
return render(request, 'plants/identify_upload.html', {'error': str(e)})
request.session['identify_matches'] = matches
request.session['identify_image_paths'] = saved_paths
return redirect('identify_confirm')
- Step 4: Run upload tests
cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_identify_views.py::test_upload_post_no_images plants/tests/test_identify_views.py::test_upload_post_calls_plantnet_and_redirects plants/tests/test_identify_views.py::test_upload_post_plantnet_error_rerenders -v
Expected: all 3 PASS.
- Step 5: Build upload template
Replace plants/templates/plants/identify_upload.html:
{% extends "plants/base.html" %}
{% block title %}Identify plant — PlantDB{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-2 mb-3">
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-secondary">←</a>
<h5 class="mb-0">Identify plant</h5>
</div>
<p class="text-muted small mb-3">
Add one or more photos, tag what each shows, then tap Identify.
</p>
{% if error %}
<div class="alert alert-warning py-2 small mb-3">{{ error }}</div>
{% endif %}
<form method="post" enctype="multipart/form-data" id="identify-form">
{% csrf_token %}
<input type="file" name="images" accept="image/jpeg,image/png" class="d-none" id="file-picker">
<div id="photo-rows" class="mb-3"></div>
<div id="add-btn-wrap" class="mb-3">
<label class="btn btn-outline-secondary w-100 py-3" for="file-picker" id="add-photo-label">
📷 Add photo <span id="photo-count" class="text-muted small"></span>
</label>
</div>
<button type="submit" class="btn btn-success w-100" id="identify-btn" disabled>
Identify →
</button>
</form>
<div class="text-center mt-3">
<a href="{% url 'plant_add' %}" class="text-muted small">Or enter name manually</a>
</div>
<div id="identify-spinner" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(255,255,255,.88); z-index:9999; align-items:center; justify-content:center; flex-direction:column; gap:12px;">
<div class="spinner-border text-success" role="status"><span class="visually-hidden">Identifying…</span></div>
<p class="text-muted small mb-0">Identifying plant…</p>
</div>
<script>
const ORGANS = [
{val: 'flower', label: '🌸 flower'},
{val: 'leaf', label: '🍃 leaf'},
{val: 'habit', label: '🌿 habit'},
{val: 'fruit', label: '🍎 fruit'},
{val: 'bark', label: '🪵 bark'},
{val: 'auto', label: '✨ auto'},
];
const selected = []; // {file, organ, objectUrl}
const picker = document.getElementById('file-picker');
picker.addEventListener('change', function () {
if (!this.files[0] || selected.length >= 5) return;
selected.push({file: this.files[0], organ: 'auto', objectUrl: URL.createObjectURL(this.files[0])});
this.value = '';
render();
});
function setOrgan(idx, organ) {
selected[idx].organ = organ;
render();
}
function removePhoto(idx) {
URL.revokeObjectURL(selected[idx].objectUrl);
selected.splice(idx, 1);
render();
}
function render() {
const rows = document.getElementById('photo-rows');
rows.innerHTML = '';
selected.forEach((item, i) => {
const row = document.createElement('div');
row.className = 'd-flex gap-2 align-items-start mb-3';
row.innerHTML = `
<div style="position:relative;flex-shrink:0;">
<img src="${item.objectUrl}" style="width:72px;height:72px;object-fit:cover;border-radius:8px;" alt="">
<button type="button" onclick="removePhoto(${i})"
style="position:absolute;top:2px;right:2px;background:rgba(0,0,0,.5);color:#fff;border:none;border-radius:50%;width:20px;height:20px;font-size:10px;cursor:pointer;padding:0;line-height:1;">✕</button>
</div>
<div style="flex:1;">
<div class="text-muted small mb-1">What's shown?</div>
<div class="d-flex flex-wrap gap-1">
${ORGANS.map(o => `
<span onclick="setOrgan(${i}, '${o.val}')"
style="cursor:pointer; padding:2px 8px; border-radius:10px; font-size:11px; ${item.organ === o.val ? 'background:#52b788;color:#fff;' : 'background:#e9ecef;color:#6c757d;'}">
${o.label}
</span>
`).join('')}
</div>
<input type="hidden" name="organs" value="${item.organ}" id="organ-${i}">
</div>`;
// Update hidden organ input when chip changes
rows.appendChild(row);
});
// Rebuild organ hidden inputs after render
document.querySelectorAll('input[name="organs"]').forEach((el, i) => {
if (selected[i]) el.value = selected[i].organ;
});
document.getElementById('photo-count').textContent = selected.length ? `(${selected.length}/5)` : '';
document.getElementById('add-btn-wrap').style.display = selected.length >= 5 ? 'none' : '';
document.getElementById('identify-btn').disabled = selected.length === 0;
}
document.getElementById('identify-form').addEventListener('submit', function (e) {
if (selected.length === 0) { e.preventDefault(); return; }
const dt = new DataTransfer();
selected.forEach(s => dt.items.add(s.file));
picker.files = dt.files;
document.getElementById('identify-spinner').style.display = 'flex';
});
</script>
{% endblock %}
- Step 6: Commit
git add plants/views/identify.py plants/templates/plants/identify_upload.html plants/tests/test_identify_views.py
git commit -m "feat: implement step 1 upload view and template"
Task 5: Step 2 — Confirm view + template
Files:
-
Modify:
plants/views/identify.py -
Modify:
plants/templates/plants/identify_confirm.html -
Modify:
plants/tests/test_identify_views.py -
Step 1: Write failing confirm view tests
Append to plants/tests/test_identify_views.py:
def _set_matches(client):
session = client.session
session['identify_matches'] = [
{'scientific_name': 'Liatris spicata', 'common_names': ['Blazing star'],
'score': 0.94, 'gbif_id': '123', 'family': 'Asteraceae', 'genus': 'Liatris'},
{'scientific_name': 'Liatris pycnostachya', 'common_names': ['Prairie blazing star'],
'score': 0.04, 'gbif_id': '456', 'family': 'Asteraceae', 'genus': 'Liatris'},
]
session['identify_image_paths'] = [{'path': 'plants/temp_identify/x_0.jpg', 'organ': 'flower'}]
session.save()
def test_confirm_get_with_session(client):
_set_matches(client)
resp = client.get(reverse('identify_confirm'))
assert resp.status_code == 200
assert b'Liatris spicata' in resp.content
def test_confirm_post_stores_selection_and_redirects(client):
_set_matches(client)
with patch('plants.views.identify.vpc_search', return_value=[
{'slug': 'liatris-spicata', 'common_name': 'Liatris spicata', 'scientific_name': 'Liatris spicata', 'thumbnail_url': '', 'source': 'vpc'},
]):
resp = client.post(reverse('identify_confirm'), {'match_idx': '0'})
assert resp.status_code == 302
assert reverse('identify_fields') in resp['Location']
assert client.session['identify_selected']['scientific_name'] == 'Liatris spicata'
assert len(client.session['identify_vpc_results']) == 1
def test_confirm_post_handles_vpc_failure(client):
_set_matches(client)
from plants.services.vpc import VPCError
with patch('plants.views.identify.vpc_search', side_effect=VPCError('down')):
resp = client.post(reverse('identify_confirm'), {'match_idx': '0'})
assert resp.status_code == 302
assert client.session['identify_vpc_results'] == []
- Step 2: Run to confirm tests fail
cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_identify_views.py::test_confirm_get_with_session plants/tests/test_identify_views.py::test_confirm_post_stores_selection_and_redirects plants/tests/test_identify_views.py::test_confirm_post_handles_vpc_failure -v
Expected: FAIL.
- Step 3: Implement confirm view
Replace identify_confirm in plants/views/identify.py:
def identify_confirm(request):
if 'identify_matches' not in request.session:
return redirect('identify_upload')
matches = request.session['identify_matches']
if request.method == 'POST':
try:
idx = int(request.POST.get('match_idx', 0))
idx = max(0, min(idx, len(matches) - 1))
except (ValueError, TypeError):
idx = 0
selected = matches[idx]
request.session['identify_selected'] = selected
try:
vpc_results = vpc_search(selected['scientific_name'])[:3]
except Exception:
vpc_results = []
request.session['identify_vpc_results'] = vpc_results
request.session['identify_vpc_idx'] = 0
return redirect('identify_fields')
return render(request, 'plants/identify_confirm.html', {'matches': matches})
- Step 4: Run confirm tests
cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_identify_views.py::test_confirm_get_with_session plants/tests/test_identify_views.py::test_confirm_post_stores_selection_and_redirects plants/tests/test_identify_views.py::test_confirm_post_handles_vpc_failure -v
Expected: all 3 PASS.
- Step 5: Build confirm template
Replace plants/templates/plants/identify_confirm.html:
{% extends "plants/base.html" %}
{% block title %}Confirm species — PlantDB{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-2 mb-3">
<a href="{% url 'identify_upload' %}" class="btn btn-sm btn-outline-secondary">←</a>
<h5 class="mb-0">Is this your plant?</h5>
</div>
<p class="text-muted small mb-3">Tap the correct species, then confirm.</p>
<form method="post" id="confirm-form">
{% csrf_token %}
<input type="hidden" name="match_idx" id="match_idx_input" value="0">
<div class="d-flex flex-column gap-2 mb-4" id="match-list">
{% for m in matches %}
<div class="match-card d-flex align-items-center gap-3 p-3 border rounded"
style="cursor:pointer; {% if forloop.first %}border-color:#52b788 !important; background:#f0faf4;{% endif %}"
data-idx="{{ forloop.counter0 }}"
onclick="selectMatch(this)">
<div class="flex-grow-1">
<div class="fw-bold">{{ m.scientific_name }}</div>
{% if m.common_names %}
<div class="text-muted small">{{ m.common_names|join:", " }}</div>
{% endif %}
<div class="text-muted small fst-italic">{{ m.family }}</div>
</div>
<div>
{% with pct=m.score|floatformat:0 %}
<span class="badge {% if m.score >= 0.70 %}bg-success{% elif m.score >= 0.30 %}bg-warning text-dark{% else %}bg-secondary{% endif %}">
{{ m.score|floatformat:"0%" }}
</span>
{% endwith %}
</div>
</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-success w-100" id="confirm-btn">
Use selected species →
</button>
</form>
<script>
function selectMatch(el) {
document.querySelectorAll('.match-card').forEach(c => {
c.style.borderColor = '';
c.style.background = '';
});
el.style.borderColor = '#52b788';
el.style.background = '#f0faf4';
document.getElementById('match_idx_input').value = el.dataset.idx;
}
</script>
{% endblock %}
Note: {{ m.score|floatformat:"0%" }} won't produce a percentage automatically in Django — replace with:
{% widthratio m.score 1 100 %}%
So the badge line becomes:
<span class="badge {% if m.score >= 0.70 %}bg-success{% elif m.score >= 0.30 %}bg-warning text-dark{% else %}bg-secondary{% endif %}">
{% widthratio m.score 1 100 %}%
</span>
- Step 6: Commit
git add plants/views/identify.py plants/templates/plants/identify_confirm.html plants/tests/test_identify_views.py
git commit -m "feat: implement step 2 species confirm view and template"
Task 6: Step 3 — Field picker view + template
Files:
-
Modify:
plants/views/identify.py -
Modify:
plants/templates/plants/identify_fields.html -
Modify:
plants/tests/test_identify_views.py -
Step 1: Write failing field picker tests
Append to plants/tests/test_identify_views.py:
from plants.models import Plant, Species
def _set_fields_session(client, vpc_results=None):
session = client.session
session['identify_selected'] = {
'scientific_name': 'Liatris spicata',
'common_names': ['Blazing star', 'Pronkster'],
'score': 0.94, 'gbif_id': '123', 'family': 'Asteraceae', 'genus': 'Liatris',
}
session['identify_vpc_results'] = vpc_results if vpc_results is not None else [
{'slug': 'liatris-spicata', 'common_name': 'Liatris spicata', 'scientific_name': 'Liatris spicata', 'thumbnail_url': '', 'source': 'vpc'},
]
session['identify_vpc_idx'] = 0
session['identify_matches'] = []
session['identify_image_paths'] = []
session.save()
@pytest.mark.django_db
def test_fields_get_with_session(client):
_set_fields_session(client)
resp = client.get(reverse('identify_fields'))
assert resp.status_code == 200
assert b'Liatris spicata' in resp.content
@pytest.mark.django_db
def test_fields_post_creates_species_and_plant(client):
_set_fields_session(client)
with patch('plants.views.identify.default_storage') as mock_storage:
mock_storage.exists.return_value = False
resp = client.post(reverse('identify_fields'), {
'common_name_source': 'plantnet',
'scientific_name_source': 'plantnet',
'name': 'Liatris',
})
assert resp.status_code == 302
assert Plant.objects.filter(name='Liatris').exists()
species = Species.objects.get(scientific_name='Liatris spicata')
assert species.common_name == 'Blazing star'
@pytest.mark.django_db
def test_fields_post_deduplicates_species(client):
existing = Species.objects.create(scientific_name='Liatris spicata', common_name='Old name')
_set_fields_session(client)
with patch('plants.views.identify.default_storage') as mock_storage:
mock_storage.exists.return_value = False
client.post(reverse('identify_fields'), {
'common_name_source': 'plantnet',
'scientific_name_source': 'plantnet',
'name': 'Liatris',
})
assert Species.objects.filter(scientific_name='Liatris spicata').count() == 1
@pytest.mark.django_db
def test_fields_post_clears_session(client):
_set_fields_session(client)
with patch('plants.views.identify.default_storage') as mock_storage:
mock_storage.exists.return_value = False
client.post(reverse('identify_fields'), {
'common_name_source': 'plantnet',
'scientific_name_source': 'plantnet',
'name': 'Liatris',
})
assert 'identify_selected' not in client.session
@pytest.mark.django_db
def test_fields_post_uses_vpc_common_name(client):
_set_fields_session(client, vpc_results=[
{'slug': 'liatris-spicata-kobold', 'common_name': "Liatris 'Kobold'",
'scientific_name': 'Liatris spicata', 'thumbnail_url': '', 'source': 'vpc'},
])
with patch('plants.views.identify.default_storage') as mock_storage:
mock_storage.exists.return_value = False
client.post(reverse('identify_fields'), {
'common_name_source': 'vpc',
'scientific_name_source': 'plantnet',
'name': 'Liatris Kobold',
})
species = Species.objects.get(scientific_name='Liatris spicata')
assert species.common_name == "Liatris 'Kobold'"
- Step 2: Run to confirm tests fail
cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_identify_views.py::test_fields_get_with_session plants/tests/test_identify_views.py::test_fields_post_creates_species_and_plant plants/tests/test_identify_views.py::test_fields_post_deduplicates_species plants/tests/test_identify_views.py::test_fields_post_clears_session plants/tests/test_identify_views.py::test_fields_post_uses_vpc_common_name -v
Expected: FAIL.
- Step 3: Implement field picker view
First, add missing imports at the top of plants/views/identify.py (after the existing imports):
from plants.models import Plant, Species
Then replace identify_fields in plants/views/identify.py and add _apply_vpc_care_fields helper:
from plants.models import Plant, Species
def _apply_vpc_care_fields(species, vpc_result):
"""Enrich species with care data from a VPC search result dict.
vpc_search returns lightweight dicts — no care data. We need scrape_plant
for the full detail. This is called only when the user saves (step 3 POST).
"""
from plants.services.vpc import scrape_plant, VPCError
slug = vpc_result.get('slug', '')
if not slug:
return
try:
data = scrape_plant(slug)
except VPCError:
return
for field in ('bloom_months', 'max_height_cm', 'sunlight', 'growth_rate',
'description', 'api_image_url', 'frost_hardiness_c',
'planting_density_m2'):
val = data.get(field)
if val not in (None, '', []):
setattr(species, field, val)
species.vpc_slug = slug
species.vpc_raw = data
species.save()
def identify_fields(request):
if 'identify_selected' not in request.session:
return redirect('identify_upload')
selected = request.session['identify_selected']
vpc_results = request.session.get('identify_vpc_results', [])
vpc_idx = request.session.get('identify_vpc_idx', 0)
# Handle VPC source change (GET with vpc_idx param)
if request.method == 'GET' and 'vpc_idx' in request.GET:
try:
new_idx = int(request.GET['vpc_idx'])
vpc_idx = max(0, min(new_idx, len(vpc_results) - 1))
request.session['identify_vpc_idx'] = vpc_idx
except (ValueError, TypeError):
pass
return redirect('identify_fields')
vpc_match = vpc_results[vpc_idx] if vpc_results else None
if request.method == 'POST':
common_name_source = request.POST.get('common_name_source', 'plantnet')
scientific_name_source = request.POST.get('scientific_name_source', 'plantnet')
name = request.POST.get('name', '').strip() or selected['scientific_name']
if common_name_source == 'vpc' and vpc_match:
common_name = vpc_match['common_name']
else:
common_name = (selected['common_names'] or [selected['scientific_name']])[0]
if scientific_name_source == 'vpc' and vpc_match:
scientific_name = vpc_match['scientific_name']
else:
scientific_name = selected['scientific_name']
species = Species.objects.filter(scientific_name=scientific_name).first()
if not species:
species = Species.objects.create(
scientific_name=scientific_name,
common_name=common_name,
)
else:
species.common_name = common_name
species.save(update_fields=['common_name'])
if vpc_match:
_apply_vpc_care_fields(species, vpc_match)
plant = Plant.objects.create(name=name, species=species)
# Clean up temp images
try:
for p in request.session.get('identify_image_paths', []):
if default_storage.exists(p['path']):
default_storage.delete(p['path'])
finally:
_clear_session(request)
return redirect('plant_detail', pk=plant.pk)
return render(request, 'plants/identify_fields.html', {
'selected': selected,
'vpc_results': vpc_results,
'vpc_idx': vpc_idx,
'vpc_match': vpc_match,
})
- Step 4: Run field picker tests
cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_identify_views.py::test_fields_get_with_session plants/tests/test_identify_views.py::test_fields_post_creates_species_and_plant plants/tests/test_identify_views.py::test_fields_post_deduplicates_species plants/tests/test_identify_views.py::test_fields_post_clears_session plants/tests/test_identify_views.py::test_fields_post_uses_vpc_common_name -v
Expected: all 5 PASS.
- Step 5: Run all identify tests
cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_identify_views.py plants/tests/test_plantnet.py -v
Expected: all tests PASS.
- Step 6: Build field picker template
Replace plants/templates/plants/identify_fields.html:
{% extends "plants/base.html" %}
{% block title %}Review & save — PlantDB{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-2 mb-3">
<a href="{% url 'identify_confirm' %}" class="btn btn-sm btn-outline-secondary">←</a>
<h5 class="mb-0">Review & save</h5>
</div>
<p class="text-muted small mb-3">Tap a tile to switch source. Green border = selected.</p>
{% if vpc_match %}
<div class="d-flex align-items-center gap-2 p-2 rounded mb-3" style="background:#d8f3dc;">
<span style="font-size:18px;">🏪</span>
<div class="flex-grow-1 small">
<div class="fw-semibold">{{ vpc_match.common_name }}</div>
<div class="text-muted" style="font-size:11px;">vasteplantencatalogus.nl</div>
</div>
{% if vpc_results|length > 1 %}
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
change
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% for r in vpc_results %}
<li>
<a class="dropdown-item {% if forloop.counter0 == vpc_idx %}active{% endif %}"
href="{% url 'identify_fields' %}?vpc_idx={{ forloop.counter0 }}">
{{ r.common_name }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% else %}
<div class="alert alert-warning py-2 small mb-3">
No VPC data found — using Pl@ntNet data only.
</div>
{% endif %}
<form method="post">
{% csrf_token %}
<!-- Common name -->
<div class="mb-3">
<div class="text-muted small text-uppercase fw-semibold mb-2" style="letter-spacing:.05em; font-size:10px;">Common name</div>
<div class="d-flex gap-2">
<div class="flex-fill source-tile p-2 border rounded"
style="cursor:pointer; {% if not vpc_match %}border-color:#52b788 !important; background:#f0faf4;{% endif %}"
data-field="common_name" data-value="plantnet" onclick="selectSource(this)">
<span class="badge mb-1" style="background:#52b788; font-size:9px;">Pl@ntNet</span>
<div class="small fw-semibold">{{ selected.common_names.0|default:selected.scientific_name }}</div>
{% if selected.common_names|length > 1 %}
<div class="text-muted" style="font-size:10px;">{{ selected.common_names.1 }}</div>
{% endif %}
</div>
{% if vpc_match %}
<div class="flex-fill source-tile p-2 border rounded"
style="cursor:pointer; border-color:#52b788 !important; background:#f0faf4;"
data-field="common_name" data-value="vpc" onclick="selectSource(this)">
<span class="badge mb-1" style="background:#6c757d; font-size:9px;">VPC</span>
<div class="small fw-semibold">{{ vpc_match.common_name }}</div>
</div>
{% endif %}
</div>
<input type="hidden" name="common_name_source" id="common_name_source"
value="{% if vpc_match %}vpc{% else %}plantnet{% endif %}">
</div>
<!-- Scientific name -->
<div class="mb-3">
<div class="text-muted small text-uppercase fw-semibold mb-2" style="letter-spacing:.05em; font-size:10px;">Scientific name</div>
<div class="d-flex gap-2">
<div class="flex-fill source-tile p-2 border rounded"
style="cursor:pointer; border-color:#52b788 !important; background:#f0faf4;"
data-field="scientific_name" data-value="plantnet" onclick="selectSource(this)">
<span class="badge mb-1" style="background:#52b788; font-size:9px;">Pl@ntNet</span>
<div class="small fw-semibold fst-italic">{{ selected.scientific_name }}</div>
</div>
{% if vpc_match %}
<div class="flex-fill source-tile p-2 border rounded"
style="cursor:pointer;"
data-field="scientific_name" data-value="vpc" onclick="selectSource(this)">
<span class="badge mb-1" style="background:#6c757d; font-size:9px;">VPC</span>
<div class="small fw-semibold fst-italic">{{ vpc_match.scientific_name }}</div>
</div>
{% endif %}
</div>
<input type="hidden" name="scientific_name_source" id="scientific_name_source" value="plantnet">
</div>
{% if vpc_match %}
<hr class="my-3">
<div class="text-muted small text-uppercase fw-semibold mb-2" style="letter-spacing:.05em; font-size:10px;">
VPC care data <span class="badge ms-1" style="background:#e9ecef; color:#6c757d; font-size:9px;">from VPC</span>
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<div class="p-2 rounded" style="background:#f8f9fa; font-size:12px;">
<div class="text-muted" style="font-size:10px; text-transform:uppercase;">Loaded after save</div>
<div class="small text-muted">bloom · height · sunlight · frost · density</div>
</div>
</div>
</div>
{% endif %}
<hr class="my-3">
<div class="mb-3">
<label class="form-label fw-semibold small">My plant name <span class="text-danger">*</span></label>
<input type="text" name="name" class="form-control"
value="{{ selected.scientific_name }}" required autofocus>
<div class="text-muted" style="font-size:11px; margin-top:3px;">How you refer to this plant in your collection</div>
</div>
<button type="submit" class="btn btn-success w-100">Save plant ✓</button>
</form>
<script>
function selectSource(el) {
const field = el.dataset.field;
document.querySelectorAll(`.source-tile[data-field="${field}"]`).forEach(t => {
t.style.borderColor = '';
t.style.background = '';
});
el.style.borderColor = '#52b788';
el.style.background = '#f0faf4';
document.getElementById(field + '_source').value = el.dataset.value;
}
// Set initial states
document.querySelectorAll('input[type="hidden"][id$="_source"]').forEach(input => {
const tile = document.querySelector(`.source-tile[data-field="${input.id.replace('_source','')}"][data-value="${input.value}"]`);
if (tile) {
tile.style.borderColor = '#52b788';
tile.style.background = '#f0faf4';
}
});
</script>
{% endblock %}
Note on VPC care data: the view calls _apply_vpc_care_fields which scrapes the full VPC plant page on save. The tile above says "Loaded after save" to be honest about when the data arrives. This can be refined later to show a preview by scraping VPC during step 2 instead.
- Step 7: Commit
git add plants/views/identify.py plants/templates/plants/identify_fields.html plants/tests/test_identify_views.py
git commit -m "feat: implement step 3 field picker view and template"
Task 7: Nav, species_search cleanup, delete button fix
Files:
-
Modify:
plants/templates/plants/base.html -
Modify:
plants/templates/plants/species_search.html -
Modify:
plants/templates/plants/plant_detail.html -
Step 1: Update nav "+" button
In plants/templates/plants/base.html, find:
<a href="{% url 'plant_add' %}" class="btn btn-sm btn-light fw-bold">+</a>
Change to:
<a href="{% url 'identify_upload' %}" class="btn btn-sm btn-light fw-bold">+</a>
- Step 2: Remove Perenual from species_search
In plants/templates/plants/species_search.html, remove the entire include_perenual checkbox section if present. Also remove the hx-include reference to it from the search button. The current species_search.html has no Perenual checkbox in it yet (it was in the search results partial). Check plants/templates/plants/partials/species_results.html — if it has a Perenual toggle, remove it.
Open plants/templates/plants/partials/species_results.html and remove any include_perenual checkbox or Perenual results section.
- Step 3: Fix delete button on plant detail
In plants/templates/plants/plant_detail.html, find the action buttons section near the bottom:
<a href="{% url 'plant_card' plant.pk %}" class="btn btn-sm btn-outline-secondary mb-2">🪧 Plant card</a>
{% if plant.card_photos.all %}
<a href="{% url 'crop_thumbnail' plant.pk %}" class="btn btn-sm btn-outline-secondary mb-2">✂️ Crop thumbnail</a>
{% endif %}
<a href="{% url 'plant_delete' plant.pk %}" class="btn btn-outline-danger btn-sm mt-2">Delete plant</a>
Replace with:
<div class="d-flex flex-wrap gap-2 mt-2">
<a href="{% url 'plant_card' plant.pk %}" class="btn btn-sm btn-outline-secondary">🪧 Plant card</a>
{% if plant.card_photos.all %}
<a href="{% url 'crop_thumbnail' plant.pk %}" class="btn btn-sm btn-outline-secondary">✂️ Crop thumbnail</a>
{% endif %}
<a href="{% url 'plant_delete' plant.pk %}" class="btn btn-sm btn-outline-danger">Delete plant</a>
</div>
- Step 4: Run full test suite
cd /home/stephan/devel/python/plantdb && python -m pytest -v
Expected: all tests PASS.
- Step 5: Commit
git add plants/templates/plants/base.html \
plants/templates/plants/species_search.html \
plants/templates/plants/partials/species_results.html \
plants/templates/plants/plant_detail.html
git commit -m "feat: update nav, remove Perenual UI, fix delete button alignment"
Task 8: Manual smoke test + deploy
- Step 1: Add
PLANTNET_API_KEYto.envon dockerhost
Get an API key from https://my.plantnet.org/signup (free, instant). Then on dockerhost:
echo "PLANTNET_API_KEY=your-key-here" >> /home/stephan/stacks/plantdb/.env
- Step 2: Deploy
rsync -av --exclude='*.pyc' --exclude='__pycache__' --exclude='.venv' --exclude='data/' --exclude='.git' --exclude='.playwright-mcp' --exclude='.superpowers' . dockerhost:/home/stephan/stacks/plantdb/
ssh dockerhost "cd /home/stephan/stacks/plantdb && docker compose up -d --build web"
- Step 3: Smoke test
Open http://dockerhost:8083, tap "+", check:
- Upload page loads
- Add a photo, verify organ chips appear, default is
auto - Tap Identify → spinner appears → species confirm page loads with matches
- Pick a species → field picker loads with Pl@ntNet + VPC data
- Edit plant name → Save → redirected to plant detail
- Verify species data (bloom months, sunlight etc.) populated from VPC
- On plant detail: verify action buttons (Plant card / Crop thumbnail / Delete) are in a consistent row on mobile
- Step 4: Final commit if any smoke-test fixes
git add -p
git commit -m "fix: smoke test corrections"