bloombase/docs/superpowers/plans/2026-05-30-plantnet-photo-id.md
Stephan Kerkman 1fa1f71291 docs: add Pl@ntNet photo ID implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:25:19 +02:00

1399 lines
49 KiB
Markdown

# 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_KEY` to settings**
In `plantdb/settings.py`, after the `PERENUAL_API_KEY` line:
```python
PLANTNET_API_KEY = os.environ.get('PLANTNET_API_KEY', '')
```
- [ ] **Step 2: Commit**
```bash
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`:
```python
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**
```bash
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`**
```python
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**
```bash
cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_plantnet.py -v
```
Expected: all 7 tests PASS.
- [ ] **Step 5: Commit**
```bash
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`:
```python
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:
```python
from plants.views import dashboard, plants, pruning, species, identify
```
Add after the existing `plants/scan/` path:
```python
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`:
```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`:
```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`:
```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**
```bash
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`:
```python
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**
```bash
cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_identify_views.py -v
```
Expected: 3 tests PASS.
- [ ] **Step 7: Commit**
```bash
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`:
```python
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**
```bash
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`:
```python
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**
```bash
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`:
```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**
```bash
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`:
```python
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**
```bash
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`:
```python
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**
```bash
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`:
```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:
```html
{% widthratio m.score 1 100 %}%
```
So the badge line becomes:
```html
<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**
```bash
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`:
```python
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**
```bash
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):
```python
from plants.models import Plant, Species
```
Then replace `identify_fields` in `plants/views/identify.py` and add `_apply_vpc_care_fields` helper:
```python
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**
```bash
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**
```bash
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`:
```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**
```bash
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:
```html
<a href="{% url 'plant_add' %}" class="btn btn-sm btn-light fw-bold">+</a>
```
Change to:
```html
<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:
```html
<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:
```html
<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**
```bash
cd /home/stephan/devel/python/plantdb && python -m pytest -v
```
Expected: all tests PASS.
- [ ] **Step 5: Commit**
```bash
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_KEY` to `.env` on dockerhost**
Get an API key from https://my.plantnet.org/signup (free, instant). Then on dockerhost:
```bash
echo "PLANTNET_API_KEY=your-key-here" >> /home/stephan/stacks/plantdb/.env
```
- [ ] **Step 2: Deploy**
```bash
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:
1. Upload page loads
2. Add a photo, verify organ chips appear, default is `auto`
3. Tap Identify → spinner appears → species confirm page loads with matches
4. Pick a species → field picker loads with Pl@ntNet + VPC data
5. Edit plant name → Save → redirected to plant detail
6. Verify species data (bloom months, sunlight etc.) populated from VPC
7. 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**
```bash
git add -p
git commit -m "fix: smoke test corrections"
```